Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats

One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.

The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.

The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 11:03:14 +02:00
1 parent f41ebf2160
commit 827ecb770e
56 files changed
+5280 -745

No files matched your search

+17 -10
View File
@@ -1,8 +1,12 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient; namespace decePubClient;
@@ -17,20 +21,23 @@ namespace decePubClient;
[JsonSerializable(typeof(PublicCacheData))] [JsonSerializable(typeof(PublicCacheData))]
[JsonSerializable(typeof(PageSettings))] [JsonSerializable(typeof(PageSettings))]
[JsonSerializable(typeof(AuthData))] [JsonSerializable(typeof(AuthData))]
[JsonSerializable(typeof(User))] [JsonSerializable(typeof(FeedFilter))]
[JsonSerializable(typeof(UserClaim))] [JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(ActionBarFilter))] [JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(List<Message>))]
[JsonSerializable(typeof(MessageUser))]
[JsonSerializable(typeof(BoostingUser))]
[JsonSerializable(typeof(Media))]
[JsonSerializable(typeof(MessageForm))]
[JsonSerializable(typeof(UploadMedia))] [JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ClientLogs))] [JsonSerializable(typeof(ClientLogs))]
[JsonSerializable(typeof(ClientLogException))] [JsonSerializable(typeof(ClientLogException))]
[JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))] [JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
[JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewPost))]
[JsonSerializable(typeof(List<ViewPost>))]
[JsonSerializable(typeof(InsertPostForm))]
public partial class ClientJsonContext : JsonSerializerContext public partial class ClientJsonContext : JsonSerializerContext
{ {
} }
+300
View File
@@ -0,0 +1,300 @@
@inherits LocalizableComponentBase
@* 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. *@
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator />
@if (form.HasContentWarning)
{
<DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="500" autocomplete="off"
placeholder="@Localizer["Content warning"]" @bind-Value="form.SpoilerText">
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput>
}
<div class="relative">
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
</div>
@if (form.Media.Count > 0)
{
<div class="flex flex-col gap-2">
@foreach (var media in form.Media)
{
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
{
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
}
else
{
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
}
<div class="flex flex-col flex-1 min-w-0 gap-1">
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" />
</div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
}
</div>
}
@if (form.IsLocated)
{
<div class="flex flex-col gap-1 rounded-xl p-3 neo-inset neo-2xs text-xs">
<span class="inline-flex gap-2 items-center">
<i class="ion-md-locate text-primary"></i>
@if (form.Latitude is null)
{
<span>@Localizer["Asking this device for its position…"]</span>
}
else
{
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span>
}
</span>
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
</div>
}
<ErrorViewer AdditionalError="@error" AdditionalErrors="fileErrors" />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null)
{
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
{
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
</DButton>
}
</ChildContent>
</DDropdown>
}
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
<i class="ion-md-warning text-base"></i>
</DButton>
@if (!form.IsLocated && GroupId is null)
{
@* A label styled as a button opens the hidden file input. *@
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
disabled="@(form.Media.Count >= MaximumFileCount)" />
<i class="ion-md-attach text-base"></i>
@if (form.Media.Count != 0)
{
<span>+@form.Media.Count</span>
}
</label>
<DSelect TValue="MediaType" Size="DSize.Sm" class="w-auto" Value="form.MediaType" ValueChanged="v => form.MediaType = v"
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<ChildContent>
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
{
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
}
</ChildContent>
</DSelect>
}
@if (InReplyTo is null && GroupId is null)
{
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i>
</DButton>
}
</div>
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
@if (isPublishing)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
<i class="ion-md-send"></i>
</DButton>
</div>
</EditForm>
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
[Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; }
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
ComposeForm form = new();
List<string> fileErrors = [];
string error;
bool isPublishing;
int ContentLength => form.Content?.Length ?? 0;
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
long MaximumFileSize => form.MediaType switch
{
MediaType.Images => 16_777_216,
MediaType.Audio => 41_943_040,
_ => 99_614_720
};
string AcceptedFileTypes => form.MediaType switch
{
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
MediaType.Video => ".mp4,.m4v,.webm,.mov",
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
_ => default
};
protected override void OnInitialized()
{
IsLoading = false;
form.GroupId = GroupId;
if (InReplyTo is null)
return;
form.InReplyToId = InReplyTo.Id;
form.MessageType = InReplyTo.Visibility switch
{
"direct" => MessageType.Direct,
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
form.SpoilerText = InReplyTo.SpoilerText;
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
.Where(acct => acct is not null && acct != CascadingState.Persona?.Acct).Distinct().Select(acct => "@" + acct);
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
}
void SetMessageType(MessageType messageType)
{
form.MessageType = messageType;
form.IsScopeOptionsOpen = false;
}
async Task ToggleLocated()
{
form.IsLocated = !form.IsLocated;
if (!form.IsLocated || form.Latitude is not null)
return;
var position = await JS.InvokeAsync<GeoPosition>("feedPosition");
if (position?.Error is { Length: > 0 } positionError)
{
form.IsLocated = false;
error = Localizer["This device did not give its position: {0}", positionError];
return;
}
form.Latitude = position?.Latitude;
form.Longitude = position?.Longitude;
}
async Task OnFileChange(InputFileChangeEventArgs eventArgs)
{
fileErrors = [];
if (eventArgs.FileCount + form.Media.Count > MaximumFileCount)
{
fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", MaximumFileCount, form.Media.Count + eventArgs.FileCount]);
return;
}
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
{
if (file.Size > MaximumFileSize)
{
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
continue;
}
if (await ReadFile(file) is { } media)
form.Media.Add(media);
else
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
}
}
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
{
try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
await fileStream.CopyToAsync(memoryStream, cancellationToken);
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
if (file.ContentType.StartsWith("image"))
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
return media;
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
return default;
}
}
async Task Publish()
{
error = default;
if (string.IsNullOrWhiteSpace(form.Content) && form.Media.Count == 0)
{
error = Localizer["Missing content, either message or media"];
return;
}
if (CascadingState.PersonaId is null)
{
error = Localizer["Choose a persona first."];
return;
}
isPublishing = true;
var result = await StatusService.Publish(form, CascadingState.PersonaId);
isPublishing = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
var published = result.Data as FeedPost;
form = new() { GroupId = GroupId };
await OnPublished.InvokeAsync(published);
}
sealed class GeoPosition
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Accuracy { get; set; }
public string Error { get; set; }
}
}
+530 -151
View File
@@ -1,167 +1,189 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<div class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"> @* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
quoted post (Compact) has none, since it is not an item of the feed. Located
posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on the time, the author, the place,
the media and the counts. *@
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact ? null : Coordinate(Post.Location?.Latitude))"
data-lng="@(Compact ? null : Coordinate(Post.Location?.Longitude))" data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())"
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)">
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center" @if (Author is { } author)
style="background-image:linear-gradient(to left, var(--color-base-100), transparent), url('@(Message.User.BackgroundUrl)');">
<a class="block h-12 w-12 md:h-16 md:w-16" href="@Message.User.ProfileUrl" title="@Message.User.UserName">
<img alt="@Message.User.UserName" class="h-12 w-12 md:h-16 md:w-16 object-cover rounded-full neo-raised neo-2xs" src="@(Message.User.PictureUrl ?? "/imgs/icon-192.png")" />
</a>
</div>
<div class="flex flex-col gap-3 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
<div class="flex flex-col gap-1 flex-1 min-w-0">
@if (Message.BoostingUser != null)
{ {
<p class="inline-flex flex-1 gap-2 min-w-0 text-xs"> <div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
<a class="font-bold" href="@Message.BoostingUser.ProfileUrl" title="@Message.BoostingUser.UserName">@Message.BoostingUser.DisplayName</a> style="@HeaderBackground(author)">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@author.Url" target="_blank" rel="noopener noreferrer" title="@author.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(author.Avatar ?? "imgs/icon-192.png")" />
</a>
</div>
}
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
@if (Post.Notification is { } notification)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
</p>
}
else if (Post.BoostedBy is { } booster)
{
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
<i class="ion-md-repeat text-info" aria-hidden="true"></i> <i class="ion-md-repeat text-info" aria-hidden="true"></i>
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@booster.Name</a>
<span>@Localizer["boosted"]</span> <span>@Localizer["boosted"]</span>
</p> </p>
} }
<p class="inline-flex flex-1 gap-2 min-w-0 justify-between text-xs md:text-sm"> else if (Post.Conversation is { } conversation)
<span class="inline-flex gap-2 min-w-0"> {
<b class="shrink truncate max-w-[80%]" title="@Message.User.DisplayName"> <p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
@Message.User.DisplayName <i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
</b> <span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
<a class="underline flex-1 min-w-6 opacity-50 truncate self-center text-xs" href="@Message.User.ProfileUrl" title="@Message.User.UserName">
@Message.User.UserName
</a>
</span>
<span class="flex-none inline-flex gap-1 items-center">
<span title="@Message.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Message.CreatedAt.GetPassedTime(Localizer)
</span>
<i aria-hidden="true" class="@Message.MessageType.GetMessageTypeIcon() text-base"
title="@Localizer[Message.MessageType.ToString()]">
</i>
</span>
</p> </p>
@if (Message.Title is { Length: > 0 })
{
<p class="text-sm md:text-base font-bold break-all">@Message.Title</p>
} }
@if (Message.Content is { Length: > 0 }) @if (Status is { } status)
{ {
<div class="text-sm md:text-base break-all"> <header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
@((MarkupString)Message.Content) <NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
</div> <span class="inline-flex gap-2 min-w-0 max-w-full">
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
</span>
</NerdTip>
<span class="flex-none inline-flex gap-2 items-center">
@if (Post.Location is { } location)
{
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
</span>
</NerdTip>
}
else if (ServerGeo?.Cdn is { Length: > 0 } cdn)
{
<span class="badge badge-ghost badge-sm gap-1" title="@Localizer["The server is behind {0}, so its place is unknown.", cdn]">
<i class="ion-md-cloud" aria-hidden="true"></i>
<span>@cdn</span>
</span>
}
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
@if (status.EditedAt is not null)
{
<span class="opacity-60">*</span>
}
</NerdTip>
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
</span>
</header>
@if (status.Privapub?.Title is { Length: > 0 } title)
{
<p class="text-sm md:text-base font-bold break-words">@title</p>
} }
@if (Message.Medias.Count != 0) @if (status.SpoilerText is { Length: > 0 } spoiler)
{ {
<div class="grid gap-4 auto-cols-auto grid-rows-1 grid-flow-col-dense"> <DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
@foreach (var media in Message.Medias) ContentClass="text-sm">
{ <Header>
if (media.ContentType.StartsWith("image")) <span class="inline-flex gap-2 items-center"><i class="ion-md-warning text-warning"></i>@spoiler</span>
{ </Header>
<a class="w-auto" href="@media.Url"> <ChildContent>
<img alt="@media.AltText" class="w-full rounded-lg @SUtility.IfTrueThen(Message.Medias.Count > 1, "max-h-[30vh]")" src="@media.Url" title="@media.AltText"> @Body(status)
</a> </ChildContent>
} </DCollapse>
else if (media.ContentType.StartsWith("video"))
{
<video class="w-full max-h-[50vh] aspect-video rounded-lg mx-auto neo-inset neo-2xs" controls="controls" playsinline="playsinline" preload="metadata" title="@media.FileName">
<source src="@media.Url" type="@media.ContentType" />
</video>
}
else if (media.ContentType.StartsWith("audio"))
{
<audio class="w-full max-h-8" controls="controls" preload="metadata" title="@media.FileName">
<source src="@media.Url" type="@media.ContentType" />
</audio>
} }
else else
{ {
<div class="flex items-center gap-3 rounded-lg p-3 md:p-4 neo-raised neo-2xs"> @Body(status)
<span> }
<i class="text-2xl ion-md-document"></i>
</span> @if (Post.Located)
<div class="flex flex-col w-full gap-1"> {
<p class="text-xs md:text-sm break-all"> <p class="inline-flex gap-2 items-center text-xs opacity-70">
<b>@media.FileName</b> <i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p> </p>
<p class="text-xs break-all">
<i class="ion-md-code"></i> @media.ContentType
</p>
</div>
<DButton Size="DSize.Sm" OnClick="async () => await OnMessageMediaDownload.InvokeAsync(media)">
<i class="ion-md-download text-base"></i>
</DButton>
</div>
}
}
</div>
} }
@if (!Post.Located && !Compact)
{
<footer class="flex gap-2 mt-1 justify-between">
<div class="flex gap-2">
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
@if (status.RepliesCount > 0)
{
<span>@status.RepliesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
@if (status.ReblogsCount > 0)
{
<span>@status.ReblogsCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
@if (status.FavouritesCount > 0)
{
<span>@status.FavouritesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</div> </div>
<div class="flex gap-3 mt-3 justify-between"> <div class="flex gap-2">
<div class="flex gap-3"> <DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
@if (OnMessageReply.HasDelegate) TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
{ ContentClass="mb-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<DButton Size="DSize.Sm" Active="isAnswering" Disabled="IsDefaultDisabled" OnClick="Reply" title="@Localizer[isAnswering ? "Close" : "Reply"]">
<i aria-hidden="true" class="@SUtility.IfTrueThen(isAnswering, "ion-md-close-circle", "ion-md-text") text-lg text-success"></i>
</DButton>
}
@if (OnMessageBoost.HasDelegate)
{
<DButton Size="DSize.Sm" Active="Message.IsBoostedByCurrentUser" Disabled="IsDefaultDisabled" OnClick="() => OnMessageBoost.InvokeAsync(Message)"
title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat @SUtility.IfTrueThen(Message.BoostsCounter != 0, null, "text-lg") text-info"></i>
@if (Message.BoostsCounter != 0)
{
<span>@Message.BoostsCounter</span>
}
</DButton>
}
@if (OnMessageFavourite.HasDelegate)
{
<DButton Size="DSize.Sm" Active="Message.IsFavourite" Disabled="IsDefaultDisabled" OnClick="() => OnMessageFavourite.InvokeAsync(Message)"
title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@SUtility.IfTrueThen(Message.IsFavourite, "ion-md-heart-dislike", "ion-md-heart") text-lg text-pink-300"></i>
</DButton>
}
</div>
<div class="flex gap-3">
<DDropdown @bind-IsOpen="Message.IsOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true"
TriggerClass="@(Message.IsOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger> <Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i> <i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger> </Trigger>
<ChildContent> <ChildContent>
@if (OnUserDirectMessage.HasDelegate) @if (status.Url is { Length: > 0 } url)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserDirectMessage.InvokeAsync(Message)"> <a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
<i aria-hidden="true" class="ion-md-paper-plane text-base text-success"></i> <i aria-hidden="true" class="ion-md-open text-base"></i>
<span>@Localizer["Direct message"]</span> <span>@Localizer["Open original"]</span>
</DButton> </a>
} }
@if (OnUserSilence.HasDelegate) @if (!IsOwn)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserSilence.InvokeAsync(Message.User)"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base drop-shadow-sm text-warning"></i> <i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span> <span>@Localizer["Mute"]</span>
</DButton> </DButton>
} <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("block"))">
@if (OnUserBlock.HasDelegate)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserBlock.InvokeAsync(Message.User)">
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i> <i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span> <span>@Localizer["Block"]</span>
</DButton> </DButton>
} }
@* Once signed in for real: only for the author's own messages (Message.User.UserId == CascadingState.User.Id). *@ else
@if (OnMessageDelete.HasDelegate)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="DeleteMessage" title="@Localizer["Delete"]"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
<i aria-hidden="true" class="ion-md-trash text-lg text-error"></i> <i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span> <span>@Localizer["Delete"]</span>
</DButton> </DButton>
} }
@@ -169,54 +191,411 @@
</DDropdown> </DDropdown>
@if (IncludeExpand) @if (IncludeExpand)
{ {
<NavLink class="btn btn-sm" href="@($"expand/{Message.MessageId}")" title="@Localizer["Expand"]"> <NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
<i aria-hidden="true" class="ion-md-expand text-lg"></i> <i aria-hidden="true" class="ion-md-expand text-lg"></i>
</NavLink> </NavLink>
} }
</div> </div>
</div> </footer>
@if (isAnswering) @if (isReplying)
{ {
<MessageUpsertForm AnsweringMessage="Message" OnMessageSubmit="SubmitReply"></MessageUpsertForm> <Composer InReplyTo="status" OnPublished="Replied" />
}
}
}
else if (Post.Notification?.Account is { } follower)
{
<p class="text-sm">
<b>@follower.Name</b> <span class="opacity-60">@("@" + follower.Acct)</span>
</p>
} }
</div> </div>
</div> </article>
@code { @code {
[Parameter] public Message Message { get; set; } = new(); [Inject] StatusService StatusService { get; set; }
[Parameter] public EventCallback<MessageForm> OnMessageReply { get; set; } [Inject] InstanceService InstanceService { get; set; }
[Parameter] public EventCallback<Message> OnMessageBoost { get; set; } [Inject] ProvenanceService ProvenanceService { get; set; }
[Parameter] public EventCallback<Message> OnMessageFavourite { get; set; }
[Parameter] public EventCallback<Message> OnMessageDelete { get; set; } [Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<Message> OnUserDirectMessage { get; set; } [Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
[Parameter] public EventCallback<Media> OnMessageMediaDownload { get; set; } [Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
[Parameter] public EventCallback<MessageUser> OnUserBlock { get; set; } [Parameter] public EventCallback<string> OnTag { get; set; }
[Parameter] public EventCallback<MessageUser> OnUserSilence { get; set; }
[Parameter] public string CssContainer { get; set; } [Parameter] public string CssContainer { get; set; }
/// <summary>Deeper shadow, for the message a thread page is about.</summary> /// <summary>Deeper shadow, for the post a thread page is about.</summary>
[Parameter] public bool Highlighted { get; set; } [Parameter] public bool Highlighted { get; set; }
[Parameter] public bool IncludeExpand { get; set; } = true; [Parameter] public bool IncludeExpand { get; set; } = true;
/// <summary>Without actions, e.g. a quoted post.</summary>
[Parameter] public bool Compact { get; set; }
bool isAnswering; bool isReplying;
bool isActing;
bool isOptionsOpen;
bool isSpoilerOpen;
bool isReadingProvenance;
readonly HashSet<string> revealedMedia = [];
Status Status => Post.Status;
Account Author => Post.Status?.Account ?? Post.Notification?.Account;
ServerInfo Server => InstanceService.Known(Post.Host);
InstanceLocation ServerGeo => Server?.Geo;
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
async Task DeleteMessage() RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
@if (status.Content is { Length: > 0 } content)
{ {
isAnswering = false; <div class="post-content text-sm md:text-base break-words">@((MarkupString)content)</div>
await OnMessageDelete.InvokeAsync(Message);
} }
async Task SubmitReply(MessageForm messageForm) @if (status.MediaAttachments.Count > 0)
{ {
isAnswering = false; <div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
await OnMessageReply.InvokeAsync(messageForm); @foreach (var media in status.MediaAttachments)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
@if (status.Sensitive && !revealedMedia.Contains(media.Id))
{
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
@onclick="() => revealedMedia.Add(media.Id)">
<i class="ion-md-eye-off mr-2"></i>@Localizer["Sensitive media, show"]
</button>
}
else
{
@Media(media, status.MediaAttachments.Count > 1)
}
</NerdTip>
}
</div>
} }
void Reply() @if (status.Card is { } card && status.MediaAttachments.Count == 0)
{ {
isAnswering = !isAnswering; <a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
@if (card.Image is { Length: > 0 } image)
{
<img alt="@card.ImageDescription" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-md" src="@image" />
} }
<span class="flex flex-col min-w-0 text-xs">
<b class="truncate">@card.Title</b>
<span class="line-clamp-2 opacity-70">@card.Description</span>
<span class="truncate opacity-50">@(card.ProviderName ?? CardHost(card.Url))</span>
</span>
</a>
}
@if (status.Poll is { } poll)
{
<div class="flex flex-col gap-1 text-xs">
@foreach (var option in poll.Options)
{
var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
<span class="relative flex justify-between gap-2"><span>@option.Title</span><span>@share.ToString("0")%</span></span>
</div>
}
<span class="opacity-60">@Localizer["{0} votes", poll.VotesCount] · @(poll.Expired ? Localizer["closed"] : Localizer["open"])</span>
</div>
}
@if (status.Privapub?.Event is { } happening)
{
<div class="flex flex-col gap-1 text-xs rounded-lg p-2 neo-inset neo-2xs">
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
@foreach (var venue in happening.Places)
{
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
}
</div>
}
@if (status.Quote?.QuotedStatus is { } quoted)
{
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
}
@if (status.Tags.Count > 0)
{
<div class="flex flex-wrap gap-1">
@foreach (var tag in status.Tags)
{
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
}
</div>
}
</div>;
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
{
"image" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
src="@(media.PreviewUrl ?? media.Url)" />
</a>,
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
</a>
};
async Task Act(StatusAction action)
{
isActing = true;
var result = await StatusService.Act(Status.Id, action);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// Unreblog answers with the original; reblog with the boost wrapper.
var updated = (Status)result.Data;
Post.Status = updated.Reblog ?? updated;
}
async Task Delete()
{
isActing = true;
var result = await StatusService.Delete(Status.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Message deleted."]);
await OnDeleted.InvokeAsync(Post);
}
async Task AccountAction(string action)
{
isActing = true;
var result = await StatusService.AccountAction(Status.Account.Id, action);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(action == "block" ? Localizer["{0} blocked.", Status.Account.Name] : Localizer["{0} muted.", Status.Account.Name]);
}
void ToggleReply() => isReplying = !isReplying;
async Task Replied(FeedPost reply)
{
isReplying = false;
Status.RepliesCount++;
await OnReplied.InvokeAsync(reply);
}
async Task ReadProvenance()
{
if (Status is null || Post.Located)
return;
isReadingProvenance = true;
await ProvenanceService.Get(Status.Id);
isReadingProvenance = false;
}
static bool CanBoost(Status status) => status.Visibility is "public" or "unlisted";
static string Coordinate(double? value) => value?.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
static string Number(double? value) => value?.ToString("0.#", System.Globalization.CultureInfo.CurrentCulture) ?? "?";
static string CardHost(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url;
// PrivaPub (like Mastodon) answers a placeholder for a missing header: /media/missing-header.png.
static string HeaderBackground(Account account) => account.Header is { Length: > 0 } header && !header.Contains("/missing-")
? $"background-image:linear-gradient(to left, var(--color-base-100), transparent), url('{header}');"
: null;
string EventTime(PrivaPubEvent happening) => happening.Start is { } start
? happening.End is { } end
? $"{start.ToLocalTime():g} – {end.ToLocalTime():t}"
: start.ToLocalTime().ToString("g")
: Localizer["Event"];
string VisibilityText(string visibility) => visibility switch
{
"public" => Localizer[MessageType.Public.ToString()],
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
"private" => Localizer[MessageType.FollowersOnly.ToString()],
"direct" => Localizer[MessageType.Direct.ToString()],
_ => Localizer["Located"]
};
static string VisibilityIcon(string visibility) => visibility switch
{
"public" => "ion-md-globe",
"unlisted" => "ion-md-unlock",
"private" => "ion-md-lock",
"direct" => "ion-md-paper-plane",
_ => "ion-md-locate"
};
static string LocationIcon(LocationSource source) => source switch
{
LocationSource.Place or LocationSource.Event => "ion-md-pin",
LocationSource.NearYou => "ion-md-locate",
LocationSource.Country => "ion-md-flag",
_ => "ion-md-planet"
};
static string NotificationIcon(string type) => type switch
{
"favourite" => "ion-md-heart text-pink-400",
"reblog" => "ion-md-repeat text-info",
"mention" => "ion-md-at text-success",
"follow" or "follow_request" => "ion-md-person-add text-primary",
"poll" => "ion-md-podium",
"update" or "quoted_update" => "ion-md-create",
"quote" => "ion-md-quote",
_ => "ion-md-notifications"
};
string NotificationText(Notification notification)
{
var name = notification.Account?.Name;
return notification.Type switch
{
"favourite" => Localizer["{0} favourited your post", name],
"reblog" => Localizer["{0} boosted your post", name],
"mention" => Localizer["{0} mentioned you", name],
"follow" => Localizer["{0} followed you", name],
"follow_request" => Localizer["{0} asked to follow you", name],
"status" => Localizer["{0} posted", name],
"poll" => Localizer["A poll you took part in has ended"],
"update" => Localizer["{0} edited a post", name],
"quote" => Localizer["{0} quoted your post", name],
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
_ => Localizer["{0}: {1}", name, notification.Type]
};
}
#region Nerd stats
IEnumerable<NerdRow> PostRows()
{
var status = Status;
yield return NerdRow.Of("id", status.Id);
yield return NerdRow.Of("created_at", status.CreatedAt);
yield return NerdRow.Of("edited_at", status.EditedAt);
yield return NerdRow.Of("uri", status.Uri);
yield return NerdRow.Of("language", status.Language);
yield return NerdRow.Of("visibility", status.Visibility);
yield return NerdRow.Of("type", status.Privapub?.ObjectType);
yield return NerdRow.Of("source", status.Privapub?.Source?.MediaType);
yield return NerdRow.Of("client", status.Application?.Name);
if (ProvenanceService.Known(status.Id) is not { } provenance)
yield break;
yield return NerdRow.Of("path", provenance.Path);
yield return NerdRow.Of("activity", provenance.Activity is { } activity ? $"{activity.Type} {activity.Id}" : null);
yield return NerdRow.Of("inbox", provenance.Inbox);
yield return NerdRow.Of("fetched_by", provenance.FetchedBy);
yield return NerdRow.Of("published", provenance.Published);
yield return NerdRow.Of("received_at", provenance.ReceivedAt);
if (provenance.ReceivedAt is { } received && provenance.Published is { } published)
yield return new(Localizer["delivery latency"], NerdRow.Duration(received - published));
yield return NerdRow.Of("signature", provenance.Signature is { } signature
? $"{signature.Scheme} {signature.Algorithm} ({string.Join(' ', signature.Headers)})"
: null);
yield return NerdRow.Of("key_id", provenance.Signature?.KeyId);
yield return NerdRow.Of("raw", provenance.RawBytes > 0 ? NerdRow.Bytes(provenance.RawBytes) + (provenance.RawTruncated ? " (truncated)" : null) : null);
yield return NerdRow.Of("sha256", provenance.RawHash is { Length: > 16 } hash ? hash[..16] + "…" : provenance.RawHash);
yield return NerdRow.Of("revisions", provenance.Revisions.Count > 0 ? provenance.Revisions.Count : null);
yield return NerdRow.Of("refetched", provenance.Refetched ? true : null);
yield return NerdRow.Of("extensions", provenance.Extensions.Count > 0 ? string.Join(", ", provenance.Extensions) : null);
yield return NerdRow.Of("@context", provenance.ContextNamespaces.Count > 0 ? string.Join(", ", provenance.ContextNamespaces) : null);
}
IEnumerable<NerdRow> AuthorRows()
{
var account = Status?.Account;
if (account is null)
yield break;
yield return NerdRow.Of("acct", account.Acct);
yield return NerdRow.Of("uri", account.Uri);
yield return NerdRow.Of("created_at", account.CreatedAt);
yield return NerdRow.Of(Localizer["followers"], account.FollowersCount);
yield return NerdRow.Of(Localizer["following"], account.FollowingCount);
yield return NerdRow.Of(Localizer["posts"], account.StatusesCount);
yield return NerdRow.Of("bot", account.Bot ? true : null);
yield return NerdRow.Of("locked", account.Locked ? true : null);
if (Server is not { } server)
yield break;
yield return NerdRow.Of(Localizer["server"], server.NodeName is { Length: > 0 } name && name != server.Host ? $"{server.Host} ({name})" : server.Host);
yield return NerdRow.Of(Localizer["software"], $"{server.Software} {server.Version}".Trim());
yield return NerdRow.Of(Localizer["Users"], server.Users is { } users
? server.ActiveMonth is { } active ? $"{users:N0} ({active:N0} {Localizer["active this month"]})" : users.ToString("N0")
: null);
yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts);
yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null);
yield return NerdRow.Of(Localizer["last delivery"], server.LastSuccessAt);
yield return NerdRow.Of(Localizer["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null);
yield return NerdRow.Of(Localizer["described"], server.DescribedAt);
}
IEnumerable<NerdRow> LocationRows()
{
var location = Post.Location;
yield return new(Localizer["source"], location.Source switch
{
LocationSource.Place => Localizer["the post's own place"],
LocationSource.Event => Localizer["the event's venue"],
LocationSource.Server => Localizer["the author's server"],
LocationSource.Country => Localizer["the author's server's country"],
_ => Localizer["Near you"]
});
yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}");
yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm:0} km");
if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo)
{
yield return NerdRow.Of(Localizer["network"], geo.Network);
yield return NerdRow.Of(Localizer["located by"], geo.Source);
yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt);
}
if (InstanceService.Known(InstanceService.SelfHost)?.Geo is { Latitude: { } selfLatitude, Longitude: { } selfLongitude } && location.Source is not LocationSource.NearYou)
yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude):N0} km");
}
static IEnumerable<NerdRow> MediaRows(MediaAttachment media)
{
yield return NerdRow.Of("type", media.Type);
yield return NerdRow.Of("size", media.Meta?.Original?.Size);
yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect);
yield return NerdRow.Of("length", media.Meta?.Length);
yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X:0.##}, {focus.Y:0.##}" : null);
yield return NerdRow.Of("blurhash", media.Blurhash);
yield return NerdRow.Of("served by", Uri.TryCreate(media.Url, UriKind.Absolute, out var served) ? served.Host : null);
yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null);
yield return NerdRow.Of("alt", media.Description is { Length: > 0 } alt ? $"{alt.Length} chars" : "none");
}
IEnumerable<NerdRow> CountRows(Status status)
{
yield return NerdRow.Of(Localizer["replies"], status.RepliesCount);
yield return NerdRow.Of(Localizer["boosts"], status.ReblogsCount);
yield return NerdRow.Of(Localizer["Favourites"], status.FavouritesCount);
yield return NerdRow.Of(Localizer["quotes"], status.QuotesCount > 0 ? status.QuotesCount : null);
yield return NerdRow.Of(Localizer["votes"], status.Privapub?.Votes is { } votes && votes.Up + votes.Down > 0 ? $"+{votes.Up} −{votes.Down}" : null);
if (status.FavouritesCount > 0)
yield return NerdRow.Of(Localizer["boosts per favourite"], Math.Round((double)status.ReblogsCount / status.FavouritesCount, 2));
}
#endregion
} }
+107
View File
@@ -0,0 +1,107 @@
@inherits LocalizableComponentBase
@* 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, Tag the hashtag being followed. The stats icon shows how the current feed was
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 items-center gap-2 min-w-0">
<div class="flex-1 min-w-0 overflow-x-auto">
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
@foreach (var type in Types)
{
<DTab Class="gap-1 whitespace-nowrap">
<Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
</Header>
</DTab>
}
</DTabs>
</div>
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
</NerdTip>
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<i class="ion-md-planet text-base"></i>
</DButton>
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
<i class="ion-md-create text-base"></i>
<span class="hidden sm:inline">@Localizer["Post"]</span>
</DButton>
</div>
@if (Filter.TimelineType is TimelineType.Groups)
{
@if (Groups.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This persona is in no community or circle yet."]</p>
}
else
{
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.GroupId" ValueChanged="SelectGroup" ValueExpression="() => Filter.GroupId">
<Prefix><i class="@(SelectedGroup?.IsCommunity == false ? "ion-md-lock" : "ion-md-contacts")"></i></Prefix>
<ChildContent>
@foreach (var group in Groups)
{
<option value="@group.Id">@(group.IsCommunity ? "◎" : "◉") @(group.Name ?? group.UserName)</option>
}
</ChildContent>
</DSelect>
}
}
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>
@code {
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { get; set; }
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
static readonly TimelineType[] Always =
[
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks
];
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
protected override void OnInitialized() => IsLoading = false;
string TabName(TimelineType type) => type switch
{
TimelineType.Federation => Localizer["Federated"],
TimelineType.Tag => "#" + Filter.Tag,
_ => Localizer[type.ToString()]
};
async Task Select(int index)
{
Filter.TimelineType = Types[index];
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
Filter.GroupId = Groups.FirstOrDefault()?.Id;
await OnChanged.InvokeAsync(Filter);
}
async Task SelectGroup(string groupId)
{
Filter.GroupId = groupId;
await OnChanged.InvokeAsync(Filter);
}
async Task ToggleGlobe()
{
Filter.ShowGlobe = !Filter.ShowGlobe;
await OnChanged.InvokeAsync(Filter);
}
}
+184
View File
@@ -0,0 +1,184 @@
@inherits LocalizableComponentBase
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
<div class="relative w-full h-full">
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
}
else
{
<span>@Localizer["loading"]…</span>
}
</span>
</NerdTip>
</div>
</div>
@code {
[Inject] IJSRuntime JS { get; set; }
[Inject] InstanceService InstanceService { get; set; }
/// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { get; set; } = [];
ElementReference element;
IJSObjectReference module;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new();
string sentFingerprint;
GlobeStats stats;
protected override void OnInitialized() => IsLoading = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
try
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
await module.InvokeVoidAsync("create", element, ScrollContainerId);
statsTimer = new(TimeSpan.FromSeconds(1));
_ = ReadStats(cancellation.Token);
}
await SendPosts();
}
catch (JSDisconnectedException)
{
}
catch (Exception ex) when (ex is JSException or InvalidOperationException)
{
await CascadingState.ProcessError(ex, $"{nameof(GlobePanel)}.{nameof(OnAfterRenderAsync)}");
}
}
/// <summary>Sends the located posts when they changed since the last call.</summary>
async Task SendPosts()
{
if (module is null)
return;
var located = Posts.Where(p => p.Location is not null).ToList();
var self = InstanceService.Known(InstanceService.SelfHost);
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}")) + $"#{self?.Geo?.Latitude}";
if (fingerprint == sentFingerprint)
return;
sentFingerprint = fingerprint;
var points = located.Select(p =>
{
var server = InstanceService.Known(p.Host)?.Geo;
return new GlobePoint(p.Key, p.Location.Latitude, p.Location.Longitude, p.Location.Source.ToString().ToLowerInvariant(), p.Location.Label, p.Host,
server?.Latitude, server?.Longitude, p.Located ? p.RangeKm : null);
}).ToList();
await module.InvokeVoidAsync("setPosts", points, SelfPoint(self));
}
static GlobeSelf SelfPoint(ServerInfo self)
{
if (self?.Geo is { Latitude: { } latitude, Longitude: { } longitude } geo)
return new(latitude, longitude, $"PrivaPub · {geo.City ?? self.Host}", self.Host);
return CountryPoints.Of(self?.Geo?.Country) is { } country
? new(country.Latitude, country.Longitude, $"PrivaPub · {InstanceService.CountryName(self.Geo.Country)}", self.Host)
: null;
}
async Task ReadStats(CancellationToken cancellationToken)
{
try
{
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable<NerdRow> StatsRows()
{
if (stats is null)
yield break;
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
}
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
public override async ValueTask DisposeAsync()
{
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.Dispose();
try
{
if (module is not null)
{
await module.InvokeVoidAsync("dispose");
await module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
}
await base.DisposeAsync();
}
sealed record GlobePoint(string Key, double Lat, double Lng, string Src, string Label, string Host, double? ServerLat, double? ServerLng, double? Range);
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
sealed class GlobeStats
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Altitude { get; set; }
public int Visible { get; set; }
public int VisibleLocated { get; set; }
public double? CentroidLatitude { get; set; }
public double? CentroidLongitude { get; set; }
public double SpreadKm { get; set; }
public int Points { get; set; }
public int Posts { get; set; }
public int Servers { get; set; }
public int Fps { get; set; }
public string Renderer { get; set; }
public string Texture { get; set; }
public bool AutoRotate { get; set; }
public bool Paused { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public string Error { get; set; }
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Globalization;
namespace decePubClient.Components.Nerd;
/// <summary>One line of a NerdTip; a row without a value is not shown.</summary>
public sealed record NerdRow(string Label, string Value)
{
public static NerdRow Of(string label, object value) => new(label, value switch
{
null => null,
DateTime time => Time(time),
double number => number.ToString("0.###", CultureInfo.InvariantCulture),
bool flag => flag ? "yes" : "no",
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString()
});
/// <summary>A UTC time to the millisecond, as PrivaPub records it.</summary>
public static string Time(DateTime time) =>
DateTime.SpecifyKind(time, time.Kind == DateTimeKind.Unspecified ? DateTimeKind.Utc : time.Kind).ToUniversalTime()
.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
/// <summary>A duration the way a person reads it: 850 ms, 12.4 s, 3 min 5 s, 2 h 10 min, 4 d 3 h.</summary>
public static string Duration(TimeSpan span)
{
var sign = span < TimeSpan.Zero ? "-" : string.Empty;
span = span.Duration();
return sign + (span.TotalSeconds switch
{
< 1 => $"{span.TotalMilliseconds:0} ms",
< 60 => $"{span.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s",
< 3600 => $"{span.Minutes} min {span.Seconds} s",
< 86400 => $"{(int)span.TotalHours} h {span.Minutes} min",
_ => $"{(int)span.TotalDays} d {span.Hours} h"
});
}
/// <summary>A byte count: 912 B, 14.2 KiB, 3.1 MiB.</summary>
public static string Bytes(long? bytes) => bytes switch
{
null => null,
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{(bytes.Value / 1024d).ToString("0.0", CultureInfo.InvariantCulture)} KiB",
_ => $"{(bytes.Value / 1048576d).ToString("0.0", CultureInfo.InvariantCulture)} MiB"
};
/// <summary>The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km).</summary>
public static double Kilometres(double latitude1, double longitude1, double latitude2, double longitude2)
{
const double radius = 6371;
double Radians(double degrees) => degrees * Math.PI / 180;
var dLatitude = Radians(latitude2 - latitude1);
var dLongitude = Radians(longitude2 - longitude1);
var a = Math.Pow(Math.Sin(dLatitude / 2), 2) + Math.Cos(Radians(latitude1)) * Math.Cos(Radians(latitude2)) * Math.Pow(Math.Sin(dLongitude / 2), 2);
return 2 * radius * Math.Asin(Math.Min(1, Math.Sqrt(a)));
}
}
+49
View File
@@ -0,0 +1,49 @@
@inherits LocalizableComponentBase
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@ChildContent
</DTooltip>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Heading { get; set; }
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
/// <summary>Shown under the rows while OnOpen's work runs.</summary>
[Parameter] public bool Pending { get; set; }
[Parameter] public EventCallback OnOpen { get; set; }
[Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; }
[Parameter] public string Class { get; set; }
bool opened;
protected override void OnInitialized() => IsLoading = false;
async Task Open()
{
if (opened || !OnOpen.HasDelegate)
return;
opened = true;
await OnOpen.InvokeAsync();
}
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>;
}
+75 -2
View File
@@ -4,6 +4,7 @@ using collAnon.Client.Services;
using decePubClient.Helpers; using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Services; using decePubClient.Services;
using Markdig; using Markdig;
@@ -20,11 +21,12 @@ using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Web; using System.Web;
namespace decePubClient.Extensions namespace decePubClient.Extensions
{ {
public static class ExtensionMethods public static partial class ExtensionMethods
{ {
#region Navigation Manager #region Navigation Manager
@@ -134,6 +136,9 @@ namespace decePubClient.Extensions
return result.Invalidate(localizer["The server reported an internal error (HTTP {0}).", (int)statusCode], (int)statusCode, return result.Invalidate(localizer["The server reported an internal error (HTTP {0}).", (int)statusCode], (int)statusCode,
FailureCodes.ServerError); FailureCodes.ServerError);
if (TryReadMastodonError(json) is { } mastodonError)
return result.Invalidate(mastodonError, (int)statusCode);
if (json.Contains("\"title\"")) if (json.Contains("\"title\""))
{ {
var validationErrors = JsonSerializer.Deserialize<ValidationErrors>(json, SUtility.DefaultSerializer); var validationErrors = JsonSerializer.Deserialize<ValidationErrors>(json, SUtility.DefaultSerializer);
@@ -154,7 +159,70 @@ namespace decePubClient.Extensions
} }
} }
/// <summary>Reads a successful response body; a cancelled read returns SUtility.CancellationDefault.</summary> /// <summary>The message of a Mastodon API or /oauth error ({"error": ..., "error_description": ...}), or null.</summary>
static string TryReadMastodonError(string json)
{
if (!json.Contains("\"error\"") || json.Contains("\"errorMessage\""))
return null;
try
{
var error = JsonSerializer.Deserialize<MastodonError>(json, SUtility.MastodonSerializer);
return error?.ErrorDescription ?? error?.Error;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Reads a successful Mastodon API body (snake_case); a cancelled read, or a body that does not fit T (written to the
/// browser console), returns SUtility.CancellationDefault.
/// </summary>
public static async Task<T> MastodonReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
try
{
return await response.Content.ReadFromJsonAsync<T>(SUtility.MastodonSerializer, cancellationToken);
}
catch (OperationCanceledException)
{
return SUtility.CancellationDefault<T>();
}
catch (JsonException ex)
{
Console.Error.WriteLine($"{nameof(MastodonReadFromJsonAsync)}<{typeof(T).Name}> {response.RequestMessage?.RequestUri}: {ex.Message}");
return SUtility.CancellationDefault<T>();
}
}
/// <summary>
/// The paging cursors of a Mastodon list: max_id from the Link header's next (older items), min_id from its prev
/// (newer items). Either is null when the header does not name it.
/// </summary>
public static (string MaxId, string MinId) PageCursors(this HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Link", out var values))
return (null, null);
string maxId = null, minId = null;
foreach (Match link in LinkHeader().Matches(string.Join(',', values)))
{
var query = HttpUtility.ParseQueryString(new Uri(link.Groups[1].Value, UriKind.Absolute).Query);
if (link.Groups[2].Value == "next")
maxId = query["max_id"];
else if (link.Groups[2].Value == "prev")
minId = query["min_id"] ?? query["since_id"];
}
return (maxId, minId);
}
[GeneratedRegex("<([^>]+)>\\s*;\\s*rel=\"([a-z]+)\"")]
private static partial Regex LinkHeader();
/// <summary>
/// Reads a successful /clientapi body; a cancelled read, or a body that does not fit T (written to the browser
/// console), returns SUtility.CancellationDefault.
/// </summary>
public static async Task<T> DefaultReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default) public static async Task<T> DefaultReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{ {
try try
@@ -165,6 +233,11 @@ namespace decePubClient.Extensions
{ {
return SUtility.CancellationDefault<T>(); return SUtility.CancellationDefault<T>();
} }
catch (JsonException ex)
{
Console.Error.WriteLine($"{nameof(DefaultReadFromJsonAsync)}<{typeof(T).Name}> {response.RequestMessage?.RequestUri}: {ex.Message}");
return SUtility.CancellationDefault<T>();
}
} }
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer) public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
+15 -56
View File
@@ -32,22 +32,11 @@ public static class GenericExtensions
{ {
var model = new IndexedDbDatabaseModel() var model = new IndexedDbDatabaseModel()
.WithName("data") .WithName("data")
.WithVersion(2) .WithVersion(3)
.WithModelId(1); .WithModelId(1);
model.AddStore(nameof(Message))
.WithKey(nameof(Message.MessageId))
.AddUniqueIndex(nameof(Message.MessageId))
.AddIndex(nameof(Message.RootMessageId))
.AddIndex(nameof(Message.User))
.AddIndex(nameof(Message.MessageType))
.AddIndex(nameof(Message.Title))
.AddIndex(nameof(Message.Content))
.AddIndex(nameof(Message.CreatedAt))
.AddIndex(nameof(Message.Medias));
// IStorage.AddLog numbers entries itself (max key + 1), so the key is Id rather than generated. Version 2 // IStorage.AddLog numbers entries itself (max key + 1), so the key is Id rather than generated. Version 2
// added this store; before it, every log write failed inside AddLog's try/catch. // added this store; version 3 left the mock feed's Message store out of the model.
model.AddStore(nameof(ClientLogs)) model.AddStore(nameof(ClientLogs))
.WithKey(nameof(ClientLogs.Id)) .WithKey(nameof(ClientLogs.Id))
.AddUniqueIndex(nameof(ClientLogs.Id)) .AddUniqueIndex(nameof(ClientLogs.Id))
@@ -74,21 +63,6 @@ public static class GenericExtensions
_ => localizer["{0}KB", size / 1_024] _ => localizer["{0}KB", size / 1_024]
}; };
public static string ParseContent(this string content, ContentType contentType)
{
switch (contentType)
{
case ContentType.PlainText:
return content;
case ContentType.HTML:
return ((MarkupString)content).Value;
case ContentType.Markdown:
return Markdown.ToHtml(content);
default:
return default;
}
}
public static string GetMessageTypeIcon(this MessageType messageType) public static string GetMessageTypeIcon(this MessageType messageType)
{ {
switch (messageType) switch (messageType)
@@ -106,35 +80,20 @@ public static class GenericExtensions
} }
} }
public static string GetTimelineTypeIcon(this TimelineType timelineType) public static string GetTimelineTypeIcon(this TimelineType timelineType) => timelineType switch
{ {
switch (timelineType) TimelineType.Home => "ion-md-home",
{ TimelineType.Local => "ion-md-people",
case TimelineType.Home: TimelineType.Federation => "ion-md-globe",
return "ion-md-home"; TimelineType.Nearby => "ion-md-locate",
case TimelineType.Local: TimelineType.Groups => "ion-md-contacts",
return "ion-md-people"; TimelineType.Direct => "ion-md-mail",
case TimelineType.Federation: TimelineType.Notifications => "ion-md-notifications",
return "ion-md-globe"; TimelineType.Favourites => "ion-md-heart",
default: TimelineType.Bookmarks => "ion-md-bookmark",
return default; TimelineType.Tag => "ion-md-pricetag",
} _ => default
} };
public static string GetContentTypeIcon(this ContentType contentType)
{
switch (contentType)
{
case ContentType.PlainText:
return "ion-md-quote";
case ContentType.HTML:
return "ion-logo-html5";
case ContentType.Markdown:
return "ion-logo-markdown";
default:
return default;
}
}
public static string GetMediaTypeIcon(this MediaType mediaType) public static string GetMediaTypeIcon(this MediaType mediaType)
{ {
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Frozen;
namespace decePubClient.Helpers;
/// <summary>
/// A point inside each country (ISO 3166-1 alpha-2), for servers PrivaPub places only by country: the spherical mean of
/// the country's zones in the tz database's zone.tab (public domain, tzdata 2026c). Regenerate it from a newer zone.tab
/// the same way when a country code is missing.
/// </summary>
public static class CountryPoints
{
public static readonly FrozenDictionary<string, (double Latitude, double Longitude)> All = new Dictionary<string, (double, double)>
{
["AD"] = (42.5, 1.5), ["AE"] = (25.3, 55.3), ["AF"] = (34.5, 69.2), ["AG"] = (17.1, -61.8), ["AI"] = (18.2, -63.1), ["AL"] = (41.3, 19.8),
["AM"] = (40.2, 44.5), ["AO"] = (-8.8, 13.2), ["AQ"] = (-82.9, 67.0), ["AR"] = (-33.6, -65.9), ["AS"] = (-14.3, -170.7), ["AT"] = (48.2, 16.3),
["AU"] = (-33.2, 142.8), ["AW"] = (12.5, -70.0), ["AX"] = (60.1, 19.9), ["AZ"] = (40.4, 49.9), ["BA"] = (43.9, 18.4), ["BB"] = (13.1, -59.6),
["BD"] = (23.7, 90.4), ["BE"] = (50.8, 4.3), ["BF"] = (12.4, -1.5), ["BG"] = (42.7, 23.3), ["BH"] = (26.4, 50.6), ["BI"] = (-3.4, 29.4),
["BJ"] = (6.5, 2.6), ["BL"] = (17.9, -62.8), ["BM"] = (32.3, -64.8), ["BN"] = (4.9, 114.9), ["BO"] = (-16.5, -68.2), ["BQ"] = (12.2, -68.3),
["BR"] = (-8.6, -50.7), ["BS"] = (25.1, -77.3), ["BT"] = (27.5, 89.7), ["BW"] = (-24.7, 25.9), ["BY"] = (53.9, 27.6), ["BZ"] = (17.5, -88.2),
["CA"] = (57.9, -93.3), ["CC"] = (-12.2, 96.9), ["CD"] = (-8.0, 21.3), ["CF"] = (4.4, 18.6), ["CG"] = (-4.3, 15.3), ["CH"] = (47.4, 8.5),
["CI"] = (5.3, -4.0), ["CK"] = (-21.2, -159.8), ["CL"] = (-41.1, -82.2), ["CM"] = (4.0, 9.7), ["CN"] = (38.7, 106.0), ["CO"] = (4.6, -74.1),
["CR"] = (9.9, -84.1), ["CU"] = (23.1, -82.4), ["CV"] = (14.9, -23.5), ["CW"] = (12.2, -69.0), ["CX"] = (-10.4, 105.7), ["CY"] = (35.1, 33.7),
["CZ"] = (50.1, 14.4), ["DE"] = (50.1, 10.9), ["DJ"] = (11.6, 43.1), ["DK"] = (55.7, 12.6), ["DM"] = (15.3, -61.4), ["DO"] = (18.5, -69.9),
["DZ"] = (36.8, 3.0), ["EC"] = (-1.5, -84.7), ["EE"] = (59.4, 24.8), ["EG"] = (30.1, 31.3), ["EH"] = (27.2, -13.2), ["ER"] = (15.3, 38.9),
["ES"] = (34.9, -8.4), ["ET"] = (9.0, 38.7), ["FI"] = (60.2, 25.0), ["FJ"] = (-18.1, 178.4), ["FK"] = (-51.7, -57.9), ["FM"] = (6.6, 157.7),
["FO"] = (62.0, -6.8), ["FR"] = (48.9, 2.3), ["GA"] = (0.4, 9.4), ["GB"] = (51.5, -0.1), ["GD"] = (12.1, -61.8), ["GE"] = (41.7, 44.8),
["GF"] = (4.9, -52.3), ["GG"] = (49.5, -2.5), ["GH"] = (5.6, -0.2), ["GI"] = (36.1, -5.3), ["GL"] = (73.0, -40.7), ["GM"] = (13.5, -16.6),
["GN"] = (9.5, -13.7), ["GP"] = (16.2, -61.5), ["GQ"] = (3.7, 8.8), ["GR"] = (38.0, 23.7), ["GS"] = (-54.3, -36.5), ["GT"] = (14.6, -90.5),
["GU"] = (13.5, 144.8), ["GW"] = (11.8, -15.6), ["GY"] = (6.8, -58.2), ["HK"] = (22.3, 114.2), ["HN"] = (14.1, -87.2), ["HR"] = (45.8, 16.0),
["HT"] = (18.5, -72.3), ["HU"] = (47.5, 19.1), ["ID"] = (-3.6, 119.0), ["IE"] = (53.3, -6.2), ["IL"] = (31.8, 35.2), ["IM"] = (54.1, -4.5),
["IN"] = (22.5, 88.4), ["IO"] = (-7.3, 72.4), ["IQ"] = (33.4, 44.4), ["IR"] = (35.7, 51.4), ["IS"] = (64.2, -21.9), ["IT"] = (41.9, 12.5),
["JE"] = (49.2, -2.1), ["JM"] = (18.0, -76.8), ["JO"] = (32.0, 35.9), ["JP"] = (35.7, 139.7), ["KE"] = (-1.3, 36.8), ["KG"] = (42.9, 74.6),
["KH"] = (11.6, 104.9), ["KI"] = (0.2, -172.0), ["KM"] = (-11.7, 43.3), ["KN"] = (17.3, -62.7), ["KP"] = (39.0, 125.7), ["KR"] = (37.5, 127.0),
["KW"] = (29.3, 48.0), ["KY"] = (19.3, -81.4), ["KZ"] = (48.1, 59.7), ["LA"] = (18.0, 102.6), ["LB"] = (33.9, 35.5), ["LC"] = (14.0, -61.0),
["LI"] = (47.1, 9.5), ["LK"] = (6.9, 79.8), ["LR"] = (6.3, -10.8), ["LS"] = (-29.5, 27.5), ["LT"] = (54.7, 25.3), ["LU"] = (49.6, 6.2),
["LV"] = (57.0, 24.1), ["LY"] = (32.9, 13.2), ["MA"] = (33.6, -7.6), ["MC"] = (43.7, 7.4), ["MD"] = (47.0, 28.8), ["ME"] = (42.4, 19.3),
["MF"] = (18.1, -63.1), ["MG"] = (-18.9, 47.5), ["MH"] = (8.1, 169.3), ["MK"] = (42.0, 21.4), ["ML"] = (12.7, -8.0), ["MM"] = (16.8, 96.2),
["MN"] = (48.2, 99.3), ["MO"] = (22.2, 113.5), ["MP"] = (15.2, 145.8), ["MQ"] = (14.6, -61.1), ["MR"] = (18.1, -16.0), ["MS"] = (16.7, -62.2),
["MT"] = (35.9, 14.5), ["MU"] = (-20.2, 57.5), ["MV"] = (4.2, 73.5), ["MW"] = (-15.8, 35.0), ["MX"] = (25.9, -102.3), ["MY"] = (2.4, 106.0),
["MZ"] = (-26.0, 32.6), ["NA"] = (-22.6, 17.1), ["NC"] = (-22.3, 166.4), ["NE"] = (13.5, 2.1), ["NF"] = (-29.1, 168.0), ["NG"] = (6.4, 3.4),
["NI"] = (12.2, -86.3), ["NL"] = (52.4, 4.9), ["NO"] = (59.9, 10.8), ["NP"] = (27.7, 85.3), ["NR"] = (-0.5, 166.9), ["NU"] = (-19.0, -169.9),
["NZ"] = (-40.5, 178.9), ["OM"] = (23.6, 58.6), ["PA"] = (9.0, -79.5), ["PE"] = (-12.1, -77.0), ["PF"] = (-16.6, -141.4), ["PG"] = (-7.9, 151.4),
["PH"] = (14.6, 121.0), ["PK"] = (24.9, 67.0), ["PL"] = (52.2, 21.0), ["PM"] = (47.0, -56.3), ["PN"] = (-25.1, -130.1), ["PR"] = (18.5, -66.1),
["PS"] = (31.5, 34.8), ["PT"] = (36.5, -17.3), ["PW"] = (7.3, 134.5), ["PY"] = (-25.3, -57.7), ["QA"] = (25.3, 51.5), ["RE"] = (-20.9, 55.5),
["RO"] = (44.4, 26.1), ["RS"] = (44.8, 20.5), ["RU"] = (63.0, 90.5), ["RW"] = (-1.9, 30.1), ["SA"] = (24.6, 46.7), ["SB"] = (-9.5, 160.2),
["SC"] = (-4.7, 55.5), ["SD"] = (15.6, 32.5), ["SE"] = (59.3, 18.1), ["SG"] = (1.3, 103.8), ["SH"] = (-15.9, -5.7), ["SI"] = (46.0, 14.5),
["SJ"] = (78.0, 16.0), ["SK"] = (48.1, 17.1), ["SL"] = (8.5, -13.2), ["SM"] = (43.9, 12.5), ["SN"] = (14.7, -17.4), ["SO"] = (2.1, 45.4),
["SR"] = (5.8, -55.2), ["SS"] = (4.8, 31.6), ["ST"] = (0.3, 6.7), ["SV"] = (13.7, -89.2), ["SX"] = (18.1, -63.0), ["SY"] = (33.5, 36.3),
["SZ"] = (-26.3, 31.1), ["TC"] = (21.5, -71.1), ["TD"] = (12.1, 15.1), ["TF"] = (-49.4, 70.2), ["TG"] = (6.1, 1.2), ["TH"] = (13.7, 100.5),
["TJ"] = (38.6, 68.8), ["TK"] = (-9.4, -171.2), ["TL"] = (-8.6, 125.6), ["TM"] = (38.0, 58.4), ["TN"] = (36.8, 10.2), ["TO"] = (-21.1, -175.2),
["TR"] = (41.0, 29.0), ["TT"] = (10.7, -61.5), ["TV"] = (-8.5, 179.2), ["TW"] = (25.1, 121.5), ["TZ"] = (-6.8, 39.3), ["UA"] = (47.7, 32.4),
["UG"] = (0.3, 32.4), ["UM"] = (24.0, 174.3), ["US"] = (47.3, -104.5), ["UY"] = (-34.9, -56.2), ["UZ"] = (40.5, 68.0), ["VA"] = (41.9, 12.5),
["VC"] = (13.2, -61.2), ["VE"] = (10.5, -66.9), ["VG"] = (18.5, -64.6), ["VI"] = (18.4, -64.9), ["VN"] = (10.8, 106.7), ["VU"] = (-17.7, 168.4),
["WF"] = (-13.3, -176.2), ["WS"] = (-13.8, -171.7), ["YE"] = (12.8, 45.2), ["YT"] = (-12.8, 45.2), ["ZA"] = (-26.2, 28.0), ["ZM"] = (-15.4, 28.3),
["ZW"] = (-17.8, 31.1)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static (double Latitude, double Longitude)? Of(string countryCode) =>
countryCode is { Length: 2 } && All.TryGetValue(countryCode, out var point) ? point : null;
}
+15 -2
View File
@@ -1,4 +1,4 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Net.Mail; using System.Net.Mail;
using System.Text.Json; using System.Text.Json;
@@ -21,13 +21,26 @@ namespace decePubClient.Helpers
/// <summary>Marks a response HttpService made up itself (a classified failure), not one the server sent.</summary> /// <summary>Marks a response HttpService made up itself (a classified failure), not one the server sent.</summary>
public const string FailureHeader = "X-DecePub-Failure"; public const string FailureHeader = "X-DecePub-Failure";
/// <summary>The JSON options for every HTTP payload: web defaults, ClientJsonContext first, reflection after.</summary> /// <summary>
/// The JSON options for /clientapi payloads and localStorage: web defaults, enums by name as PrivaPub writes them
/// (numbers still read), ClientJsonContext first, reflection after.
/// </summary>
public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web) public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver()) TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver())
}; };
/// <summary>The JSON options for PrivaPub's Mastodon API and /oauth: snake_case, MastodonJsonContext first.</summary>
public static JsonSerializerOptions MastodonSerializer { get; } = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true,
TypeInfoResolver = JsonTypeInfoResolver.Combine(MastodonJsonContext.Default, new DefaultJsonTypeInfoResolver())
};
/// <summary>What a cancelled request turns into: HTTP 410, which AddResponseError ignores.</summary> /// <summary>What a cancelled request turns into: HTTP 410, which AddResponseError ignores.</summary>
public static WebResult DefaultTaskCancelledResult => public static WebResult DefaultTaskCancelledResult =>
new WebResult().Invalidate("Request cancelled, not an error.", (int)HttpStatusCode.Gone, FailureCodes.Cancelled); new WebResult().Invalidate("Request cancelled, not an error.", (int)HttpStatusCode.Gone, FailureCodes.Cancelled);
+105 -4
View File
@@ -1,27 +1,44 @@
@implements IAsyncDisposable @implements IAsyncDisposable
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved @* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
PublicCacheData with its theme settings, the signed-in user, and whether the app is online, polled every 10 seconds. *@ PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
compare PersonaId to reload what belongs to the persona. *@
<CascadingValue IsFixed=false Value=this> <CascadingValue IsFixed=false Value=this>
@ChildContent @ChildContent
</CascadingValue> </CascadingValue>
@code { @code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Inject] ILocalStorageService Storage { get; set; } [Inject] ILocalStorageService Storage { get; set; }
[Inject] IJSRuntime JS { get; set; } [Inject] IJSRuntime JS { get; set; }
[Inject] public AppStatusService Status { get; set; } [Inject] public AppStatusService Status { get; set; }
[Inject] ILoggingService Logger { get; set; } [Inject] ILoggingService Logger { get; set; }
[Inject] ToastService Toast { get; set; } [Inject] ToastService Toast { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] InstanceService InstanceService { get; set; }
public bool IsOnline { get; private set; } = true; public bool IsOnline { get; private set; } = true;
public bool IsOffline => !IsOnline; public bool IsOffline => !IsOnline;
public PublicCacheData PublicCacheData { get; private set; } = new(); public PublicCacheData PublicCacheData { get; private set; } = new();
/// <summary>The signed-in user; Faker's until the login is wired to PrivaPub.</summary> /// <summary>Whether a root is signed in (its JWT is still valid).</summary>
public User User { get; private set; } = Faker.CurrentUser; public bool IsSignedIn { get; private set; }
/// <summary>The root's login name, shown to its owner only.</summary>
public string RootUserName { get; private set; }
/// <summary>The root's personas.</summary>
public List<ViewAvatar> Personas { get; private set; } = [];
/// <summary>The avatar id of the persona the client acts as.</summary>
public string PersonaId { get; private set; }
/// <summary>That persona's Mastodon account, null until it is read.</summary>
public Account Persona { get; private set; }
DotNetObjectReference<CascadingState> cascadingStateReference; DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer; PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer;
readonly CancellationTokenSource disposeCancellation = new(); readonly CancellationTokenSource disposeCancellation = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -38,6 +55,10 @@
IsOnline = await Status.IsOnline(cancellationToken); IsOnline = await Status.IsOnline(cancellationToken);
onlineTimer = new(TimeSpan.FromSeconds(10)); onlineTimer = new(TimeSpan.FromSeconds(10));
_ = PollOnline(cancellationToken); _ = PollOnline(cancellationToken);
await LoadSession(cancellationToken);
refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -61,6 +82,85 @@
return UpdatePublicCache(PublicCacheData, cancellationToken); return UpdatePublicCache(PublicCacheData, cancellationToken);
} }
/// <summary>
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
/// acts as the last one used (the first one otherwise).
/// </summary>
public async Task LoadSession(CancellationToken cancellationToken = default)
{
await AuthService.RefreshIfNeeded(cancellationToken);
IsSignedIn = await AuthStateProvider.IsAuthenticatedAsync(cancellationToken);
if (!IsSignedIn)
{
ClearSession();
StateHasChanged();
return;
}
var session = await AuthStateProvider.GetAuthData(cancellationToken);
RootUserName = session.UserName;
Personas = await AuthService.Personas(cancellationToken);
var personaId = Personas.Any(p => p.Id == session.PersonaId) ? session.PersonaId : Personas.FirstOrDefault()?.Id;
if (personaId is null)
{
PersonaId = null;
Persona = null;
StateHasChanged();
return;
}
await SwitchPersona(personaId, cancellationToken);
}
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
public async Task<WebResult> SwitchPersona(string personaId, CancellationToken cancellationToken = default)
{
var result = await AuthService.UsePersona(personaId, cancellationToken);
if (result.IsInvalid)
return result;
PersonaId = personaId;
Persona = await AuthService.CurrentAccount(cancellationToken);
if (Persona?.Host is { } host)
InstanceService.SelfHost = host;
StateHasChanged();
return result;
}
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default)
{
await AuthService.Logout(cancellationToken);
ClearSession();
StateHasChanged();
}
void ClearSession()
{
IsSignedIn = false;
RootUserName = null;
Personas = [];
PersonaId = null;
Persona = null;
}
async Task KeepSessionFresh(CancellationToken cancellationToken)
{
try
{
while (await refreshTimer.WaitForNextTickAsync(cancellationToken))
if (IsSignedIn && IsOnline)
await AuthService.RefreshIfNeeded(cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(KeepSessionFresh)}", cancellationToken);
}
}
[JSInvokable] [JSInvokable]
public async Task LogFromJs(string message, string where) => public async Task LogFromJs(string message, string where) =>
await ProcessError(new(message), where); await ProcessError(new(message), where);
@@ -106,6 +206,7 @@
await disposeCancellation.CancelAsync(); await disposeCancellation.CancelAsync();
disposeCancellation.Dispose(); disposeCancellation.Dispose();
onlineTimer?.Dispose(); onlineTimer?.Dispose();
refreshTimer?.Dispose();
cascadingStateReference?.Dispose(); cascadingStateReference?.Dispose();
} }
} }
+33
View File
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
using decePubClient.Models.Mastodon;
namespace decePubClient;
/// <summary>
/// Source-generated JSON metadata for PrivaPub's Mastodon API and /oauth (snake_case), used by
/// SUtility.MastodonSerializer. ClientJsonContext stays camelCase for /clientapi and localStorage. Read-only properties
/// (Account.Host, Account.Name) are computed, never sent.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true, PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(Status))]
[JsonSerializable(typeof(List<Status>))]
[JsonSerializable(typeof(StatusContext))]
[JsonSerializable(typeof(Account))]
[JsonSerializable(typeof(List<Account>))]
[JsonSerializable(typeof(Relationship))]
[JsonSerializable(typeof(MediaAttachment))]
[JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(List<Notification>))]
[JsonSerializable(typeof(Conversation))]
[JsonSerializable(typeof(List<Conversation>))]
[JsonSerializable(typeof(InstanceDescription))]
[JsonSerializable(typeof(List<InstanceDescription>))]
[JsonSerializable(typeof(Provenance))]
[JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(MastodonError))]
[JsonSerializable(typeof(PostStatusForm))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+65
View File
@@ -0,0 +1,65 @@
namespace decePubClient.Models;
/// <summary>
/// Every PrivaPub route the client calls, relative to AppConfiguration.ApiBaseAddress (b2b.next's naming: the HTTP
/// method first, _M_ for a route built from parameters). HttpService picks the credentials from the prefix:
/// clientapi/ the root's JWT, api/ the current persona's Mastodon token, oauth/ none.
/// </summary>
public static class APIs
{
public const string ClientApiPrefix = "clientapi/";
public const string MastodonPrefix = "api/";
public const string OAuthPrefix = "oauth/";
public static class ClientApi
{
public const string POST_Login = "clientapi/user/login";
public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string GET_CurrentVersion = "clientapi/data/current-version";
public const string GET_Personas = "clientapi/avatar/private/list";
public const string GET_Groups = "clientapi/group/list";
public const string GET_Posts = "clientapi/post/list";
public const string GET_Nearby = "clientapi/post/nearby";
public const string POST_InsertPost = "clientapi/post/insert";
}
public static class OAuth
{
public const string POST_Token = "oauth/token";
public const string POST_Revoke = "oauth/revoke";
public const string TokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange";
public const string JwtTokenType = "urn:ietf:params:oauth:token-type:jwt";
public const string Scopes = "read write follow";
}
public static class Mastodon
{
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
public const string GET_HomeTimeline = "api/v1/timelines/home";
public const string GET_PublicTimeline = "api/v1/timelines/public";
public const string GET_Statuses = "api/v1/statuses";
public const string POST_Statuses = "api/v1/statuses";
public const string GET_Favourites = "api/v1/favourites";
public const string GET_Bookmarks = "api/v1/bookmarks";
public const string GET_Conversations = "api/v1/conversations";
public const string GET_Notifications = "api/v1/notifications";
public const string POST_Media = "api/v2/media";
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
}
public static class PrivaPub
{
public const string GET_Instances = "api/privapub/v1/instances";
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
}
}
+5
View File
@@ -4,4 +4,9 @@ public class AppConfiguration
{ {
public string Version { get; set; } public string Version { get; set; }
public string ApiBaseAddress { get; set; } public string ApiBaseAddress { get; set; }
/// <summary>PrivaPub's first-party application, the only one allowed to exchange the root JWT for persona tokens.</summary>
public string ClientId { get; set; } = "decepub";
/// <summary>PrivaPub's own host, which the instance API describes like any other server.</summary>
public string ApiHost => Uri.TryCreate(ApiBaseAddress, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
} }
+15 -13
View File
@@ -1,22 +1,24 @@
namespace decePubClient.Models namespace decePubClient.Models
{ {
/// <summary>
/// The signed-in session, kept in localStorage: the root's /clientapi JWT and, per persona the client used, the
/// Mastodon token PrivaPub issued for it in exchange (one token per persona, never naming the root).
/// </summary>
public class AuthData public class AuthData
{ {
/// <summary>The root's JWT, for /clientapi and for exchanging persona tokens.</summary>
public string Token { get; set; } public string Token { get; set; }
/// <summary>When the JWT expires, in .NET ticks (JwtUser.Expiration).</summary>
public long? TokenExpiration { get; set; } public long? TokenExpiration { get; set; }
/// <summary>When the client received the JWT, in .NET ticks: with TokenExpiration, how long PrivaPub's tokens last.</summary>
public long? TokenIssuedAt { get; set; }
public string CurrentLanguageCode { get; set; } = "en"; public string CurrentLanguageCode { get; set; } = "en";
public User User { get; set; } /// <summary>The root's login name; shown only to its owner.</summary>
public string UserName { get; set; }
//public bool UserHasPolicies(params string[] policies) public List<string> Policies { get; set; } = [];
//{ /// <summary>The avatar id of the persona the client acts as.</summary>
// var hasAllPolicies = true; public string PersonaId { get; set; }
// foreach (var policy in policies) /// <summary>Avatar id → that persona's Mastodon access token.</summary>
// if (!(User?.Policies?.Any(p => p == policy) ?? false)) public Dictionary<string, string> PersonaTokens { get; set; } = [];
// {
// hasAllPolicies = false;
// break;
// }
// return hasAllPolicies;
//}
} }
} }
+53
View File
@@ -0,0 +1,53 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using decePubClient.Models.Types;
using decePubClient.Resources;
namespace decePubClient.Models;
/// <summary>
/// A new post or reply. It goes through the Mastodon API (plain text, a content warning, a visibility and media) unless
/// it is located (text only, kept within RangeKm of where it was written, never federated) or meant for a group; those
/// go through /clientapi/post/insert.
/// </summary>
public class ComposeForm
{
public const int MaxContentLength = 5_000;
/// <summary>The status a reply answers.</summary>
public string InReplyToId { get; set; }
/// <summary>The community or circle the post is for.</summary>
public string GroupId { get; set; }
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
public MessageType MessageType { get; set; } = MessageType.Public;
public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string SpoilerText { get; set; }
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string Content { get; set; }
public List<UploadMedia> Media { get; set; } = [];
/// <summary>A located post: shown only to readers within RangeKm of Latitude/Longitude.</summary>
public bool IsLocated { get; set; }
[Range(1, 50)]
public double RangeKm { get; set; } = 5;
public double? Latitude { get; set; }
public double? Longitude { get; set; }
[JsonIgnore, Bindable(false)]
public MediaType MediaType { get; set; } = MediaType.Images;
[JsonIgnore, Bindable(false)]
public bool IsScopeOptionsOpen { get; set; }
/// <summary>The Mastodon visibility of MessageType.</summary>
public string Visibility => MessageType switch
{
MessageType.Direct => "direct",
MessageType.FollowersOnly => "private",
MessageType.Unlisted => "unlisted",
_ => "public"
};
}
+15
View File
@@ -0,0 +1,15 @@
using decePubClient.Models.Types;
namespace decePubClient.Models;
/// <summary>The feed page's choices, kept in localStorage: the tab, the group it shows and the globe's visibility.</summary>
public class FeedFilter
{
public TimelineType TimelineType { get; set; } = TimelineType.Home;
/// <summary>The community or circle the Groups tab shows.</summary>
public string GroupId { get; set; }
/// <summary>The hashtag the Tag tab shows, without #.</summary>
public string Tag { get; set; }
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
public bool ShowGlobe { get; set; }
}
+103
View File
@@ -0,0 +1,103 @@
using decePubClient.Models.Mastodon;
namespace decePubClient.Models;
/// <summary>
/// One item of a feed, whatever its source: a status (a boost shows its original, BoostedBy names the booster), a
/// notification, a direct conversation, or a located post from /clientapi/post/nearby (Located, with no Mastodon
/// actions: PrivaPub keeps those posts off the Mastodon API). Location is filled by InstanceService once the author's
/// server is known.
/// </summary>
public class FeedPost
{
/// <summary>Unique within a feed: the status, notification or conversation id.</summary>
public string Key { get; init; }
/// <summary>The id the feed pages by (max_id/min_id).</summary>
public string CursorId { get; init; }
/// <summary>The post shown; null for a notification without one (a follow).</summary>
public Status Status { get; set; }
public Account BoostedBy { get; init; }
public Notification Notification { get; init; }
public Conversation Conversation { get; init; }
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
public bool Located { get; init; }
public double? DistanceKm { get; init; }
public double? RangeKm { get; init; }
public FeedLocation Location { get; set; }
/// <summary>The account the item is about: the post's author, or whoever caused the notification.</summary>
public Account Author => Status?.Account ?? Notification?.Account;
public string Host => Author?.Host;
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
public static FeedPost Of(Status status) => status.Reblog is { } original
? new() { Key = status.Id, CursorId = status.Id, Status = original, BoostedBy = status.Account }
: new() { Key = status.Id, CursorId = status.Id, Status = status };
public static FeedPost Of(Notification notification) => new()
{
Key = notification.Id,
CursorId = notification.Id,
Status = notification.Status?.Reblog ?? notification.Status,
Notification = notification
};
public static FeedPost Of(Conversation conversation) => new()
{
Key = conversation.Id,
CursorId = conversation.LastStatus?.Id,
Status = conversation.LastStatus,
Conversation = conversation
};
}
/// <summary>Where a feed item is drawn on the globe, and how sure that is.</summary>
public enum LocationSource
{
/// <summary>The post's own place (Pixelfed): exact.</summary>
Place,
/// <summary>An event's venue: exact.</summary>
Event,
/// <summary>The author's server, to 0.1°.</summary>
Server,
/// <summary>The author's server, known only by country: a point in that country.</summary>
Country,
/// <summary>A located post, within its range of the reader: drawn at the reader.</summary>
NearYou
}
public sealed record FeedLocation(double Latitude, double Longitude, LocationSource Source, string Label, double PrecisionKm);
/// <summary>What the client keeps about a server (from /api/privapub/v1/instances), cached for a day.</summary>
public class ServerInfo
{
public string Host { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public string NodeName { get; set; }
public bool? OpenRegistrations { get; set; }
public long? Users { get; set; }
public long? ActiveMonth { get; set; }
public long? LocalPosts { get; set; }
public InstanceLocation Geo { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTime? LastSuccessAt { get; set; }
public DateTime? LastFailureAt { get; set; }
public DateTime? DescribedAt { get; set; }
public string DescriptionError { get; set; }
public DateTime FetchedAt { get; set; }
/// <summary>True when PrivaPub did not know the server: nothing to show, asked again later.</summary>
public bool Unknown { get; set; }
}
/// <summary>A page of a feed and the cursors around it.</summary>
public class FeedPage
{
public List<FeedPost> Posts { get; set; } = [];
/// <summary>Older items start below this id; null at the end of the feed.</summary>
public string MaxId { get; set; }
/// <summary>Newer items start above this id.</summary>
public string MinId { get; set; }
}
+58
View File
@@ -0,0 +1,58 @@
namespace decePubClient.Models.Mastodon;
/// <summary>A persona, a remote account or a community, as PrivaPub's Mastodon API sends it.</summary>
public class Account
{
public string Id { get; set; }
public string Username { get; set; }
/// <summary>username for a local account, username@host for a remote one.</summary>
public string Acct { get; set; }
public string DisplayName { get; set; }
public bool Locked { get; set; }
public bool Bot { get; set; }
public bool Group { get; set; }
public DateTime? CreatedAt { get; set; }
public string Note { get; set; }
public string Url { get; set; }
public string Uri { get; set; }
public string Avatar { get; set; }
public string AvatarStatic { get; set; }
public string Header { get; set; }
public string HeaderStatic { get; set; }
public int FollowersCount { get; set; }
public int FollowingCount { get; set; }
public int StatusesCount { get; set; }
public string LastStatusAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
public List<AccountField> Fields { get; set; } = [];
/// <summary>The account's server: the host of its ActivityPub id.</summary>
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
/// <summary>The name to show: the display name, or the username when there is none.</summary>
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
}
public class AccountField
{
public string Name { get; set; }
public string Value { get; set; }
public DateTime? VerifiedAt { get; set; }
}
public class CustomEmoji
{
public string Shortcode { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
}
public class Relationship
{
public string Id { get; set; }
public bool Following { get; set; }
public bool FollowedBy { get; set; }
public bool Blocking { get; set; }
public bool Muting { get; set; }
public bool Requested { get; set; }
}
+41
View File
@@ -0,0 +1,41 @@
namespace decePubClient.Models.Mastodon;
/// <summary>
/// A picture, video or audio file. Url and PreviewUrl go through PrivaPub (its own files or its media proxy);
/// RemoteUrl is where a remote file came from.
/// </summary>
public class MediaAttachment
{
public string Id { get; set; }
/// <summary>image, gifv, video, audio or unknown.</summary>
public string Type { get; set; }
public string Url { get; set; }
public string PreviewUrl { get; set; }
public string RemoteUrl { get; set; }
public MediaMeta Meta { get; set; }
public string Description { get; set; }
public string Blurhash { get; set; }
}
public class MediaMeta
{
public MediaSize Original { get; set; }
/// <summary>h:mm:ss.ff, for video and audio.</summary>
public string Length { get; set; }
public MediaFocus Focus { get; set; }
}
public class MediaSize
{
public int? Width { get; set; }
public int? Height { get; set; }
public string Size { get; set; }
public double? Aspect { get; set; }
public double? Duration { get; set; }
}
public class MediaFocus
{
public double X { get; set; }
public double Y { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace decePubClient.Models.Mastodon;
/// <summary>
/// type: mention, status, reblog, follow, follow_request, favourite, poll, update, quote, quoted_update or
/// pleroma:emoji_reaction. Status is null for follows.
/// </summary>
public class Notification
{
public string Id { get; set; }
public string Type { get; set; }
public DateTime CreatedAt { get; set; }
public Account Account { get; set; }
public Status Status { get; set; }
public string Emoji { get; set; }
}
/// <summary>A direct conversation: its participants and its last post.</summary>
public class Conversation
{
public string Id { get; set; }
public bool Unread { get; set; }
public List<Account> Accounts { get; set; } = [];
public Status LastStatus { get; set; }
}
+122
View File
@@ -0,0 +1,122 @@
using System.Text.Json.Nodes;
namespace decePubClient.Models.Mastodon;
/// <summary>/api/privapub/v1/instances: what PrivaPub stored about a server, and where it is.</summary>
public class InstanceDescription
{
public string Host { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = [];
public bool? OpenRegistrations { get; set; }
public DateTime? DescribedAt { get; set; }
public string DescriptionError { get; set; }
public JsonNode NodeInfo { get; set; }
public InstanceDelivery Delivery { get; set; }
public InstanceLocation Geo { get; set; }
}
public class InstanceDelivery
{
public int ConsecutiveFailures { get; set; }
public DateTime? UnavailableUntil { get; set; }
public DateTime? LastSuccessAt { get; set; }
public DateTime? LastFailureAt { get; set; }
}
/// <summary>
/// The public projection of a server's place. Precision: city (coordinates to 0.1°, city and network), country (the
/// country only) or cdn (the CDN's name only).
/// </summary>
public class InstanceLocation
{
public string Precision { get; set; }
public string Country { get; set; }
public string City { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Network { get; set; }
public string Cdn { get; set; }
public string Source { get; set; }
public DateTime? LocatedAt { get; set; }
}
/// <summary>/api/privapub/v1/statuses/{id}/provenance: how a remote post reached PrivaPub. Path is local for a local post.</summary>
public class Provenance
{
public string ObjectUri { get; set; }
public string ObjectType { get; set; }
public string Url { get; set; }
/// <summary>delivered, fetched, unrecorded or local.</summary>
public string Path { get; set; }
public bool Refetched { get; set; }
public ProvenanceActivity Activity { get; set; }
public string Inbox { get; set; }
public string FetchedBy { get; set; }
public ProvenanceSignature Signature { get; set; }
public DateTime? ReceivedAt { get; set; }
public DateTime? Published { get; set; }
public DateTime? Updated { get; set; }
public List<string> Extensions { get; set; } = [];
public List<string> ContextNamespaces { get; set; } = [];
public JsonNode Raw { get; set; }
public int RawBytes { get; set; }
public string RawHash { get; set; }
public bool RawTruncated { get; set; }
public List<ProvenanceRevision> Revisions { get; set; } = [];
public InstanceDescription Instance { get; set; }
}
public class ProvenanceActivity
{
public string Id { get; set; }
public string Type { get; set; }
public string Actor { get; set; }
}
public class ProvenanceSignature
{
public string Scheme { get; set; }
public string KeyId { get; set; }
public string Algorithm { get; set; }
public List<string> Headers { get; set; } = [];
}
public class ProvenanceRevision
{
public string ActivityId { get; set; }
public DateTime? Updated { get; set; }
public DateTime? ReceivedAt { get; set; }
public string RawHash { get; set; }
}
/// <summary>The answer of /oauth/token.</summary>
public class TokenResponse
{
public string AccessToken { get; set; }
public string TokenType { get; set; }
public string IssuedTokenType { get; set; }
public string Scope { get; set; }
public long CreatedAt { get; set; }
}
/// <summary>An error of the Mastodon API or of /oauth: {"error": ..., "error_description": ...}.</summary>
public class MastodonError
{
public string Error { get; set; }
public string ErrorDescription { get; set; }
}
/// <summary>POST /api/v1/statuses. The text is plain: the Mastodon API does not take Markdown.</summary>
public class PostStatusForm
{
public string Status { get; set; }
public List<string> MediaIds { get; set; } = [];
public string InReplyToId { get; set; }
public bool Sensitive { get; set; }
public string SpoilerText { get; set; }
public string Visibility { get; set; }
public string Language { get; set; }
}
+90
View File
@@ -0,0 +1,90 @@
namespace decePubClient.Models.Mastodon;
/// <summary>Status.privapub: what a Mastodon status cannot say (PrivaPub's Mastodon client API invariant 7).</summary>
public class PrivaPubStatus
{
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
public string ObjectType { get; set; }
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
public PrivaPubSource Source { get; set; }
public PrivaPubLink Link { get; set; }
public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
public PrivaPubPlace Place { get; set; }
public PrivaPubVotes Votes { get; set; }
}
public class PrivaPubSource
{
public string Content { get; set; }
public string MediaType { get; set; }
}
public class PrivaPubLink
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Image { get; set; }
public bool FromPublisher { get; set; }
}
public class PrivaPubVariant
{
public string Url { get; set; }
public string MediaType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public double? Fps { get; set; }
public long? Size { get; set; }
}
public class PrivaPubVideo
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = [];
public bool IsLive { get; set; }
public long? Views { get; set; }
public string Licence { get; set; }
public string Category { get; set; }
}
public class PrivaPubAudio
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = [];
public string Album { get; set; }
public string Licence { get; set; }
}
public class PrivaPubEvent
{
public DateTime? Start { get; set; }
public DateTime? End { get; set; }
public string TimeZone { get; set; }
public List<PrivaPubPlace> Places { get; set; } = [];
public string OnlineUrl { get; set; }
public int? Participants { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
}
public class PrivaPubPlace
{
public string Name { get; set; }
public string Address { get; set; }
public string Country { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Url { get; set; }
}
public class PrivaPubVotes
{
public int Up { get; set; }
public int Down { get; set; }
}
+109
View File
@@ -0,0 +1,109 @@
namespace decePubClient.Models.Mastodon;
/// <summary>
/// A post as PrivaPub's Mastodon API sends it (snake_case JSON, MastodonJsonContext). PrivaPub-only details are in
/// Privapub; ids are opaque strings.
/// </summary>
public class Status
{
public string Id { get; set; }
public string Uri { get; set; }
public string Url { get; set; }
public Account Account { get; set; }
public string InReplyToId { get; set; }
public string InReplyToAccountId { get; set; }
public Status Reblog { get; set; }
public string Content { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? EditedAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
public int RepliesCount { get; set; }
public int ReblogsCount { get; set; }
public int FavouritesCount { get; set; }
public bool Reblogged { get; set; }
public bool Favourited { get; set; }
public bool Muted { get; set; }
public bool Bookmarked { get; set; }
public bool Pinned { get; set; }
public bool Sensitive { get; set; }
public string SpoilerText { get; set; }
/// <summary>public, unlisted, private (followers and circles) or direct.</summary>
public string Visibility { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = [];
public List<StatusMention> Mentions { get; set; } = [];
public List<StatusTag> Tags { get; set; } = [];
public PreviewCard Card { get; set; }
public Poll Poll { get; set; }
public StatusApplication Application { get; set; }
public string Language { get; set; }
/// <summary>The plain text source, only on the author's own deleted or edited post.</summary>
public string Text { get; set; }
public PrivaPubStatus Privapub { get; set; }
public Quote Quote { get; set; }
public int QuotesCount { get; set; }
}
public class StatusMention
{
public string Id { get; set; }
public string Username { get; set; }
public string Url { get; set; }
public string Acct { get; set; }
}
public class StatusTag
{
public string Name { get; set; }
public string Url { get; set; }
}
public class StatusApplication
{
public string Name { get; set; }
public string Website { get; set; }
}
public class Quote
{
public string State { get; set; }
public Status QuotedStatus { get; set; }
public string QuotedStatusId { get; set; }
}
public class StatusContext
{
public List<Status> Ancestors { get; set; } = [];
public List<Status> Descendants { get; set; } = [];
}
public class Poll
{
public string Id { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool Expired { get; set; }
public bool Multiple { get; set; }
public int VotesCount { get; set; }
public int? VotersCount { get; set; }
public bool? Voted { get; set; }
public List<PollOption> Options { get; set; } = [];
}
public class PollOption
{
public string Title { get; set; }
public int? VotesCount { get; set; }
}
public class PreviewCard
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Type { get; set; }
public string ProviderName { get; set; }
public string Image { get; set; }
public string ImageDescription { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string Blurhash { get; set; }
}
+10 -2
View File
@@ -1,9 +1,17 @@
namespace decePubClient.Models.Types namespace decePubClient.Models.Types
{ {
/// <summary>The feeds the feed page has a tab for. The first three keep their old numbers: FeedFilter is stored.</summary>
public enum TimelineType public enum TimelineType
{ {
Home, Home,
Local, Local,
Federation Federation,
Nearby,
Groups,
Direct,
Notifications,
Favourites,
Bookmarks,
Tag
} }
} }
+65 -124
View File
@@ -1,11 +1,19 @@
@page "/expand" @page "/expand/{statusId}"
@page "/expand/{messageId}" @attribute [Authorize]
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<Title>@Localizer["Thread"]</Title>
<div class="flex w-full h-full flex-col gap-4"> @* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
The cards do their own actions; a reply or a deletion updates the thread here. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <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 @SUtility.IfTrueThen(TimeSortingType is TimeSortingType.Descending, "flex-col-reverse")"> <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">
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
<i class="ion-md-arrow-back"></i>
</DButton>
<h1 class="font-bold">@Localizer["Thread"]</h1>
</div>
@if (IsLoading) @if (IsLoading)
{ {
@@ -14,7 +22,7 @@
@Localizer["loading"] @Localizer["loading"]
</p> </p>
} }
else if (IsNotFound) else if (current is null)
{ {
<p class="w-full text-center text-lg"> <p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This message doesn't exist."] <i class="ion-ios-remove-circle-outline"></i> @Localizer["This message doesn't exist."]
@@ -22,61 +30,39 @@
} }
else else
{ {
@foreach (var message in Ancestors) @foreach (var post in ancestors)
{ {
<Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message" <Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
} }
<Content Highlighted="true" IncludeExpand="false" Message="CurrentMessage" <Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
@foreach (var message in Descendants) @foreach (var post in descendants)
{ {
<Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message" <Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
} }
} }
</div> </div>
</section> </section>
</div>
@code { @code {
[Inject] IStorage DbStorage { get; set; } [Inject] StatusService StatusService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; } [Inject] IJSRuntime JS { get; set; }
[Inject] MessagesService MessagesService { get; set; }
[Parameter] public string MessageId { get; set; }
List<Message> Ancestors { get; set; } = []; [Parameter] public string StatusId { get; set; }
Message CurrentMessage { get; set; } = new();
List<Message> Descendants { get; set; } = []; FeedPost current;
bool IsNotFound { get; set; } List<FeedPost> ancestors = [];
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending; List<FeedPost> descendants = [];
string loadedId;
// Parameters rather than initialization: following a link to another message reuses this page.
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (string.IsNullOrEmpty(MessageId)) if (StatusId == loadedId)
{
Navigation.NavigateTo("");
return; return;
} loadedId = StatusId;
IsLoading = true;
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new();
TimeSortingType = filters.TimeSortingType;
if (CascadingState.IsOffline) if (CascadingState.IsOffline)
{ {
@@ -87,103 +73,58 @@
} }
await LoadData(); await LoadData();
IsLoading = false;
} }
// The thread: the message from IndexedDB, the ones around it mock (Faker) until the app reads PrivaPub's context.
async Task LoadData() async Task LoadData()
{ {
var message = await DbStorage.GetMessage(MessageId); IsLoading = true;
IsNotFound = message is null; var status = await StatusService.Get(StatusId);
if (IsNotFound) if (status is null)
return;
CurrentMessage = message;
(Ancestors, Descendants) = Faker.Thread(message);
}
async Task OnMessageReply(MessageForm messageForm)
{ {
var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User); current = null;
if (result.IsInvalid) IsLoading = false;
{
Toast.AddResponseError(result);
return; return;
} }
Descendants.Add(result.Data as Message); var context = await StatusService.Context(status.Id);
current = FeedPost.Of(status);
ancestors = [.. (context?.Ancestors ?? []).Select(FeedPost.Of)];
descendants = [.. (context?.Descendants ?? []).Select(FeedPost.Of)];
IsLoading = false;
var all = ancestors.Append(current).Concat(descendants).ToList();
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(all);
} }
async Task OnMessageBoost(Message message) void Remove(FeedPost post)
{ {
if (message.IsBoostedByCurrentUser) if (post.Key == current?.Key)
return;
var result = await MessagesService.BoostUnboostMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnMessageFavourite(Message message)
{ {
var result = await MessagesService.FavouriteUnfavouriteMessage(message); current = null;
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnMessageDelete(Message message)
{
var result = await MessagesService.DeleteMessage(message);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return; return;
} }
ancestors.RemoveAll(p => p.Key == post.Key);
descendants.RemoveAll(p => p.Key == post.Key);
}
Toast.AddResponseSuccess(Localizer["Message deleted."]); async Task Replied(FeedPost reply)
if (message == CurrentMessage)
{ {
if (reply is null)
return;
descendants.Add(reply);
await InstanceService.Resolve([reply.Host]);
InstanceService.Locate([reply]);
}
async Task OpenTag(string tag)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tag;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo(""); Navigation.NavigateTo("");
return;
} }
Ancestors.Remove(message); async Task Back() => await JS.InvokeVoidAsync("history.back");
Descendants.Remove(message);
}
async Task OnMessageMediaDownload(Media media)
{
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType);
}
async Task OnUserBlock(MessageUser messageUser)
{
var result = await MessagesService.BlockUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
}
async Task OnUserDirectMessage(Message message)
{
var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnUserSilence(MessageUser messageUser)
{
var result = await MessagesService.SilenceUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
}
} }
+339 -119
View File
@@ -1,205 +1,425 @@
@page "/" @page "/"
@page "/home" @page "/home"
@page "/index" @page "/index"
@attribute [Authorize]
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<Title>@Localizer["Index"]</Title> <Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
<div class="flex w-full h-full flex-col gap-4"> @* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js); newer ones are polled every
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto @SUtility.IfTrueThen(TimeSortingType is TimeSortingType.Descending, "flex-col-reverse")"> <GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
</aside>
@if (IsLoading) <section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
<div class="flex flex-col gap-4">
@if (isComposing)
{
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</div>
}
@if (pending.Count > 0)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
<i class="ion-md-arrow-up"></i>
<span>@Localizer["{0} new", pending.Count]</span>
</DButton>
}
@if (Filter.TimelineType is TimelineType.Nearby && position is null)
{
<div class="flex flex-col items-center gap-2 text-sm text-center">
<p>@Localizer["Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only."]</p>
@if (positionError is { Length: > 0 })
{
<p class="text-error">@positionError</p>
}
<DButton Size="DSize.Sm" OnClick="AskPosition" Disabled="IsDefaultDisabled">
<i class="ion-md-locate"></i><span>@Localizer["Use my position"]</span>
</DButton>
</div>
}
else if (IsLoading)
{ {
<p class="flex w-full items-center justify-center gap-2"> <p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" /> <DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"] @Localizer["loading"]
</p> </p>
} }
else if (Messages.Count == 0) else if (loadError is { Length: > 0 })
{
<DAlert Color="DAlertColor.Error" Variant="DAlertVariant.Soft" class="text-sm">
<span>@loadError</span>
<DButton Size="DSize.Sm" OnClick="Reload">@Localizer["Retry"]</DButton>
</DAlert>
}
else if (posts.Count == 0)
{ {
<p class="w-full text-center text-lg"> <p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"] <i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p> </p>
} }
@foreach (var message in Messages) @foreach (var post in posts)
{ {
<Content Message="message" <Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageSubmit(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
} }
@*<AuthorizeView>
<Authorized>
@foreach (var message in Messages)
{
<Content Message="message"
OnMessageReply="(m) => OnMessageReply(m)" OnMessageBoost="(m) => OnMessageBoost(m)"
OnMessageFavourite="(m) => OnMessageFavourite(m)" OnMessageDelete="(m) => OnMessageDelete(m)"
OnUserBlock="(u) => OnUserBlock(u)" OnUserDirectMessage="(u) => OnUserDirectMessage(u)" OnUserMute="(u) => OnUserMute(u)" />
}
</Authorized>
<NotAuthorized>
@foreach (var message in Messages)
{
<Content Message="message" OnMessageReply="(m) => OnMessageReply(m)" />
}
</NotAuthorized>
</AuthorizeView>*@
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (!IsLoading && posts.Count > 0 && maxId is null)
{
<span>@Localizer["That's all."]</span>
}
</div>
</div>
</div>
</div> </div>
</section> </section>
<ActionBar OnMessageSubmit="OnMessageSubmit" OnTimelineChanged="OnTimelineChanged" OnTimeSortingChanged="OnTimeSortingChanged" />
</div> </div>
@code { @code {
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; } const string ScrollId = "feed-scroll";
[Inject] IStorage Storage { get; set; } const string SentinelId = "feed-end";
[Inject] ILocalStorageService LocalStorage { get; set; } static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
[Inject] MessagesService MessagesService { get; set; }
TimelineType TimelineType { get; set; } = TimelineType.Home; [Inject] ILocalStorageService LocalStorage { get; set; }
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending; [Inject] IJSRuntime JS { get; set; }
List<Message> Messages { get; set; } = []; [Inject] TimelineService TimelineService { get; set; }
[Inject] GroupService GroupService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
List<FeedPost> pending = [];
List<ViewGroup> groups = [];
string maxId;
string minId;
string loadError;
bool isLoadingMore;
bool isComposing;
(double Latitude, double Longitude)? position;
string positionError;
string loadedPersonaId;
FeedQuery lastQuery;
DateTime? lastPollAt;
TimeSpan? lastLoadTook;
GlobePanel globe;
IJSObjectReference feedModule;
DotNetObjectReference<Index> reference;
PeriodicTimer pollTimer;
CancellationTokenSource pageCancellation = new();
CancellationTokenSource loadCancellation;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new(); Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
TimelineType = filters.TimelineType; if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
TimeSortingType = filters.TimeSortingType; Filter.TimelineType = TimelineType.Home;
if (CascadingState.IsOffline) if (CascadingState.IsOffline)
{ {
WhenOnline(LoadData); WhenOnline(Reload);
StartOnlinePolling(); StartOnlinePolling();
IsLoading = false; IsLoading = false;
return; return;
} }
await LoadData(); if (CascadingState.PersonaId is null)
{
IsLoading = false; IsLoading = false;
}
// The feed: the mock one in IndexedDB until the app reads PrivaPub's timeline.
async Task LoadData()
{
Messages = await Storage.GetMessages();
}
void OnTimelineChanged(TimelineType timelineType)
{
TimelineType = timelineType;
}
void OnTimeSortingChanged(TimeSortingType timeSortingType)
{
TimeSortingType = timeSortingType;
}
// Covers replies too: the form carries the RootMessageId.
async Task OnMessageSubmit(MessageForm messageForm)
{
var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return; return;
} }
Messages.Insert(0, result.Data as Message); await Reload();
} }
async Task OnMessageBoost(Message message) // The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
protected override async Task OnParametersSetAsync()
{ {
var result = await MessagesService.BoostUnboostMessage(message); if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
if (result.IsInvalid) return;
await Reload();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
Toast.AddResponseError(result); await base.OnAfterRenderAsync(firstRender);
if (!firstRender)
return;
reference = DotNetObjectReference.Create(this);
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
pollTimer = new(PollInterval);
_ = PollNewer(pageCancellation.Token);
}
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
Filter.Tag, position?.Latitude, position?.Longitude);
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
async Task Reload()
{
loadCancellation?.Cancel();
loadCancellation = new();
var cancellationToken = loadCancellation.Token;
IsLoading = true;
loadError = default;
pending = [];
posts = [];
maxId = default;
minId = default;
StateHasChanged();
if (CascadingState.PersonaId != loadedPersonaId)
{
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
loadedPersonaId = CascadingState.PersonaId;
}
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
Filter.GroupId = groups.FirstOrDefault()?.Id;
lastQuery = Query;
if (Filter.TimelineType is TimelineType.Nearby && position is null)
{
IsLoading = false;
return; return;
} }
if (!message.IsBoostedByCurrentUser) var started = DateTime.UtcNow;
var result = await TimelineService.Load(Query, cancellationToken: cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
lastLoadTook = DateTime.UtcNow - started;
IsLoading = false;
if (result.IsInvalid)
{ {
Messages.RemoveAll(m => m.MessageId == message.MessageId && m.BoostingUser?.UserId == CascadingState.User.Id); if (result.StatusCode is not 410)
loadError = result.ErrorMessage;
return; return;
} }
// The feed shows a boost as a copy of the message carrying the booster. var page = (FeedPage)result.Data;
Messages.Insert(0, new() posts = page.Posts;
{ maxId = page.MaxId;
BoostingUser = CascadingState.User.ToBoostingUser(), minId = page.MinId;
BoostsCounter = message.BoostsCounter, lastPollAt = DateTime.UtcNow;
Content = message.Content, StateHasChanged();
CreatedAt = message.CreatedAt, await Locate(posts, cancellationToken);
IsBoostedByCurrentUser = true, if (feedModule is not null)
IsFavourite = message.IsFavourite, await feedModule.InvokeVoidAsync("checkEnd");
Medias = message.Medias,
MessageId = message.MessageId,
MessageType = message.MessageType,
RootMessageId = message.RootMessageId,
Title = message.Title,
User = message.User
});
} }
async Task OnMessageFavourite(Message message) /// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
{ {
var result = await MessagesService.FavouriteUnfavouriteMessage(message); await InstanceService.Resolve(located.Select(p => p.Host).Append(InstanceService.SelfHost), cancellationToken);
if (result.IsInvalid) InstanceService.Locate(located, position);
Toast.AddResponseError(result); StateHasChanged();
} }
async Task OnMessageDelete(Message message) [JSInvokable]
public async Task NearEnd()
{ {
var result = await MessagesService.DeleteMessage(message); if (IsLoading || isLoadingMore || maxId is null)
return;
isLoadingMore = true;
StateHasChanged();
var cancellationToken = loadCancellation?.Token ?? default;
var result = await TimelineService.Load(Query, maxId: maxId, cancellationToken: cancellationToken);
isLoadingMore = false;
if (cancellationToken.IsCancellationRequested)
return;
if (result.IsInvalid) if (result.IsInvalid)
{ {
Toast.AddResponseError(result); Toast.AddResponseError(result);
StateHasChanged();
return; return;
} }
Messages.Remove(message); var page = (FeedPage)result.Data;
Toast.AddResponseSuccess(Localizer["Message deleted."]); var known = posts.Select(p => p.Key).ToHashSet();
var older = page.Posts.Where(p => known.Add(p.Key)).ToList();
posts.AddRange(older);
maxId = older.Count == 0 ? null : page.MaxId;
StateHasChanged();
await Locate(older, cancellationToken);
if (feedModule is not null)
await feedModule.InvokeVoidAsync("checkEnd");
} }
async Task OnMessageMediaDownload(Media media) /// <summary>Asks for posts newer than the newest shown, while the page is visible; they wait behind the "new" button.</summary>
async Task PollNewer(CancellationToken cancellationToken)
{ {
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType); try
{
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
{
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
continue;
var query = Query;
var result = await TimelineService.Load(query, minId: pending.FirstOrDefault()?.CursorId ?? minId, cancellationToken: cancellationToken);
lastPollAt = DateTime.UtcNow;
if (result.IsInvalid || query != Query)
continue;
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
if (newer.Count == 0)
continue;
pending.InsertRange(0, newer);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
await CascadingState.ProcessError(ex, $"{nameof(Index)}.{nameof(PollNewer)}", cancellationToken);
}
} }
async Task OnUserBlock(MessageUser messageUser) async Task ShowPending()
{ {
var result = await MessagesService.BlockUserFromMessage(messageUser); var shown = pending;
if (result.IsInvalid) pending = [];
posts.InsertRange(0, shown);
minId = shown.FirstOrDefault()?.CursorId ?? minId;
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
await Locate(shown, loadCancellation?.Token ?? default);
}
/// <summary>Saves the choices; reloads only when they change what the feed shows (not for the globe's toggle).</summary>
async Task ChangeFeed(FeedFilter filter)
{ {
Toast.AddResponseError(result); await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (Query == lastQuery)
return;
await Reload();
}
async Task OpenTag(string tag)
{
Filter.Tag = tag;
Filter.TimelineType = TimelineType.Tag;
await ChangeFeed(Filter);
}
async Task AskPosition()
{
positionError = default;
var answer = await JS.InvokeAsync<GeoAnswer>("feedPosition");
if (answer?.Latitude is not { } latitude || answer.Longitude is not { } longitude)
{
positionError = Localizer["This device did not give its position: {0}", answer?.Error ?? "?"];
return; return;
} }
Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]); position = (latitude, longitude);
await Reload();
} }
async Task OnUserDirectMessage(Message message) async Task Published(FeedPost post)
{ {
var result = await MessagesService.ReplyMessage(message, MessageType.Direct); isComposing = false;
if (result.IsInvalid) if (post is null)
Toast.AddResponseError(result);
}
async Task OnUserSilence(MessageUser messageUser)
{ {
var result = await MessagesService.SilenceUserFromMessage(messageUser); if (Filter.TimelineType is TimelineType.Nearby or TimelineType.Groups)
if (result.IsInvalid) await Reload();
{
Toast.AddResponseError(result);
return; return;
} }
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]); if (Filter.TimelineType is TimelineType.Home or TimelineType.Local || post.Status?.Visibility == "direct" && Filter.TimelineType is TimelineType.Direct)
{
posts.Insert(0, post);
await Locate([post], loadCancellation?.Token ?? default);
}
}
async Task Replied(FeedPost reply)
{
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
return;
posts.Insert(0, reply);
await Locate([reply], loadCancellation?.Token ?? default);
}
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
}
IEnumerable<NerdRow> FeedStats()
{
yield return NerdRow.Of(Localizer["items"], posts.Count);
yield return NerdRow.Of(Localizer["waiting"], pending.Count > 0 ? pending.Count : null);
yield return NerdRow.Of("min_id", minId);
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
{
yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}");
yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs:0} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}");
yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining);
}
}
public override async ValueTask DisposeAsync()
{
await pageCancellation.CancelAsync();
pageCancellation.Dispose();
loadCancellation?.Cancel();
pollTimer?.Dispose();
try
{
if (feedModule is not null)
{
await feedModule.InvokeVoidAsync("dispose");
await feedModule.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
}
reference?.Dispose();
await base.DisposeAsync();
}
sealed class GeoAnswer
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Error { get; set; }
} }
} }
+118 -3
View File
@@ -1,15 +1,130 @@
@page "/login" @page "/login"
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<Title>@Localizer["Login"]</Title> <Title>@Localizer["Login"]</Title>
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
one (CascadingState.LoadSession picks the last one used, or the only one). *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <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 flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<LoginDisplay />
@if (!CascadingState.IsSignedIn)
{
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking">
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Login"]</span>
</DButton>
</EditForm>
}
else if (CascadingState.Personas.Count == 0)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
<p class="text-sm">@Localizer["This login has no persona to act as. Create one on PrivaPub first."]</p>
<DButton OnClick="SignOut">@Localizer["Logout"]</DButton>
</div>
}
else
{
<div class="flex flex-col gap-4 w-full max-w-md mt-6 md:mt-16">
<h1 class="text-xl font-bold text-center">@Localizer["Who are you today?"]</h1>
<p class="text-sm opacity-70 text-center">@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]</p>
@foreach (var persona in CascadingState.Personas)
{
<button type="button" class="flex items-center gap-3 p-3 rounded-xl text-left neo-raised neo-xs hover:neo-sm transition-shadow @(persona.Id == CascadingState.PersonaId ? "ring-2 ring-primary" : null)"
disabled="@(IsDefaultDisabled || isWorking)" @onclick="() => Choose(persona.Id)">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full neo-raised neo-2xs" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@(persona.Name ?? persona.UserName)</b>
<span class="text-xs opacity-70 truncate">@(persona.Handle ?? "@" + persona.UserName)</span>
</span>
@if (persona.Id == CascadingState.PersonaId)
{
<i class="ion-md-checkmark-circle text-primary text-xl ml-auto"></i>
}
</button>
}
<ErrorViewer AdditionalError="@error" />
</div>
}
</div> </div>
</section> </section>
@code { @code {
[Inject] AuthService AuthService { get; set; }
readonly LoginForm form = new();
string error;
bool isWorking;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
protected override void OnInitialized() => IsLoading = false;
async Task SignIn()
{
error = default;
isWorking = true;
var result = await AuthService.Login(form);
if (result.IsInvalid)
{
error = result.ErrorMessage;
isWorking = false;
return;
}
await CascadingState.LoadSession();
isWorking = false;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue();
}
async Task Choose(string personaId)
{
error = default;
isWorking = true;
var result = await CascadingState.SwitchPersona(personaId);
isWorking = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
Continue();
}
async Task SignOut() => await CascadingState.SignOut();
/// <summary>Back where the reader was sent from, never off the app.</summary>
void Continue()
{
var target = ReturnUrl is { Length: > 0 } url && !url.Contains("://") && !url.StartsWith("//") && !url.StartsWith("login") ? url : "";
Navigation.NavigateTo(target);
}
} }
+4 -8
View File
@@ -1,4 +1,4 @@
@page "/logout" @page "/logout"
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<Title>@Localizer["Logout"]</Title> <Title>@Localizer["Logout"]</Title>
@@ -11,14 +11,10 @@
</section> </section>
@code { @code {
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; } // Revokes every persona token the client holds, ends the root's session and clears the local data.
// The app signs in with its own token (TokenAuthStateProvider), so signing out clears that token and the local
// database. The template's authentication/logout route can't do it: RemoteAuthenticatorView needs the OIDC
// provider this app replaces, and throws.
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await AuthStateProvider.LogoutAsync(true); await CascadingState.SignOut();
Navigation.NavigateTo(""); Navigation.NavigateTo("login");
} }
} }
+8 -8
View File
@@ -10,9 +10,6 @@ using decePubClient.Services;
using Toolbelt.Blazor.Extensions.DependencyInjection; using Toolbelt.Blazor.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using collAnon.Client.Services; using collAnon.Client.Services;
@@ -21,7 +18,6 @@ builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddBlazorDownloadFile(); builder.Services.AddBlazorDownloadFile();
builder.Services.AddApiAuthorization();
builder.Services.AddOptions() builder.Services.AddOptions()
.AddAuthorizationCore(options => .AddAuthorizationCore(options =>
{ {
@@ -30,9 +26,6 @@ builder.Services.AddOptions()
options.AddPolicy(Policies.IsModerator, ExtensionMethods.IsUserModeratorPolicy()); options.AddPolicy(Policies.IsModerator, ExtensionMethods.IsUserModeratorPolicy());
}) })
.AddTransient<AppStatusService>(); .AddTransient<AppStatusService>();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>,
ApiAuthorizationOptionsConfiguration>());
builder.Services.AddBlazoredLocalStorage(config => builder.Services.AddBlazoredLocalStorage(config =>
{ {
config.JsonSerializerOptions.WriteIndented = false; config.JsonSerializerOptions.WriteIndented = false;
@@ -43,8 +36,15 @@ builder.Services.AddOptions()
config.JsonSerializerOptions.TypeInfoResolver = SUtility.DefaultSerializer.TypeInfoResolver; config.JsonSerializerOptions.TypeInfoResolver = SUtility.DefaultSerializer.TypeInfoResolver;
}) })
.AddScoped<TokenAuthStateProvider>() .AddScoped<TokenAuthStateProvider>()
.AddScoped<MessagesService>()
.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthStateProvider>()) .AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthStateProvider>())
.AddScoped<RequestStats>()
.AddScoped<AuthService>()
.AddScoped<TimelineService>()
.AddScoped<StatusService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
.AddScoped<InstanceService>()
.AddScoped<ProvenanceService>()
.AddScoped<ILoggingService, LoggingService>() .AddScoped<ILoggingService, LoggingService>()
.AddScoped<ToastService>() .AddScoped<ToastService>()
.AddSingleton(typeof(CoalescingStringLocalizer)) .AddSingleton(typeof(CoalescingStringLocalizer))
+354 -33
View File
@@ -50,9 +50,6 @@
<data name="Alternative text" xml:space="preserve"> <data name="Alternative text" xml:space="preserve">
<value>Testo alternativo</value> <value>Testo alternativo</value>
</data> </data>
<data name="Ascending" xml:space="preserve">
<value>Crescente</value>
</data>
<data name="Audio" xml:space="preserve"> <data name="Audio" xml:space="preserve">
<value>Audio</value> <value>Audio</value>
</data> </data>
@@ -83,15 +80,9 @@
<data name="Delete" xml:space="preserve"> <data name="Delete" xml:space="preserve">
<value>Elimina</value> <value>Elimina</value>
</data> </data>
<data name="Descending" xml:space="preserve">
<value>Decrescente</value>
</data>
<data name="Direct" xml:space="preserve"> <data name="Direct" xml:space="preserve">
<value>Diretto</value> <value>Diretto</value>
</data> </data>
<data name="Direct message" xml:space="preserve">
<value>Messaggio diretto</value>
</data>
<data name="Documents" xml:space="preserve"> <data name="Documents" xml:space="preserve">
<value>Documenti</value> <value>Documenti</value>
</data> </data>
@@ -128,15 +119,9 @@
<data name="Home" xml:space="preserve"> <data name="Home" xml:space="preserve">
<value>Home</value> <value>Home</value>
</data> </data>
<data name="HTML" xml:space="preserve">
<value>HTML</value>
</data>
<data name="Images" xml:space="preserve"> <data name="Images" xml:space="preserve">
<value>Immagini</value> <value>Immagini</value>
</data> </data>
<data name="Index" xml:space="preserve">
<value>Home</value>
</data>
<data name="Instance" xml:space="preserve"> <data name="Instance" xml:space="preserve">
<value>Istanza</value> <value>Istanza</value>
</data> </data>
@@ -164,9 +149,6 @@
<data name="Logout" xml:space="preserve"> <data name="Logout" xml:space="preserve">
<value>Esci</value> <value>Esci</value>
</data> </data>
<data name="Markdown" xml:space="preserve">
<value>Markdown</value>
</data>
<data name="Message deleted." xml:space="preserve"> <data name="Message deleted." xml:space="preserve">
<value>Messaggio eliminato.</value> <value>Messaggio eliminato.</value>
</data> </data>
@@ -188,15 +170,9 @@
<data name="Not found" xml:space="preserve"> <data name="Not found" xml:space="preserve">
<value>Non trovato</value> <value>Non trovato</value>
</data> </data>
<data name="oh shit... here we go again" xml:space="preserve">
<value>oh no... ci risiamo</value>
</data>
<data name="Other" xml:space="preserve"> <data name="Other" xml:space="preserve">
<value>Altro</value> <value>Altro</value>
</data> </data>
<data name="PlainText" xml:space="preserve">
<value>Testo semplice</value>
</data>
<data name="Policies" xml:space="preserve"> <data name="Policies" xml:space="preserve">
<value>Regole</value> <value>Regole</value>
</data> </data>
@@ -221,18 +197,12 @@
<data name="Settings" xml:space="preserve"> <data name="Settings" xml:space="preserve">
<value>Impostazioni</value> <value>Impostazioni</value>
</data> </data>
<data name="Show preview" xml:space="preserve">
<value>Mostra anteprima</value>
</data>
<data name="Sorry, there's nothing at this address." xml:space="preserve"> <data name="Sorry, there's nothing at this address." xml:space="preserve">
<value>Spiacenti, a questo indirizzo non c'è nulla.</value> <value>Spiacenti, a questo indirizzo non c'è nulla.</value>
</data> </data>
<data name="Supplied file &quot;{0}&quot; with size {1} exceeds the maximum of {2}." xml:space="preserve"> <data name="Supplied file &quot;{0}&quot; with size {1} exceeds the maximum of {2}." xml:space="preserve">
<value>Il file "{0}" pesa {1} e supera il massimo di {2}.</value> <value>Il file "{0}" pesa {1} e supera il massimo di {2}.</value>
</data> </data>
<data name="The action could not be completed." xml:space="preserve">
<value>Non è stato possibile completare l'azione.</value>
</data>
<data name="The file &quot;{0}&quot; could not be read." xml:space="preserve"> <data name="The file &quot;{0}&quot; could not be read." xml:space="preserve">
<value>Impossibile leggere il file "{0}".</value> <value>Impossibile leggere il file "{0}".</value>
</data> </data>
@@ -263,9 +233,6 @@
<data name="This message doesn't exist." xml:space="preserve"> <data name="This message doesn't exist." xml:space="preserve">
<value>Questo messaggio non esiste.</value> <value>Questo messaggio non esiste.</value>
</data> </data>
<data name="Title (optional)" xml:space="preserve">
<value>Titolo (facoltativo)</value>
</data>
<data name="Unlisted" xml:space="preserve"> <data name="Unlisted" xml:space="preserve">
<value>Non in elenco</value> <value>Non in elenco</value>
</data> </data>
@@ -320,4 +287,358 @@
<data name="{0}MB" xml:space="preserve"> <data name="{0}MB" xml:space="preserve">
<value>{0}MB</value> <value>{0}MB</value>
</data> </data>
<data name="A located post needs this device&#x27;s position." xml:space="preserve">
<value>Un post localizzato richiede la posizione di questo dispositivo.</value>
</data>
<data name="A poll you took part in has ended" xml:space="preserve">
<value>Un sondaggio a cui hai partecipato è terminato</value>
</data>
<data name="Asking this device for its position…" xml:space="preserve">
<value>Sto chiedendo la posizione a questo dispositivo…</value>
</data>
<data name="Attach" xml:space="preserve">
<value>Allega</value>
</data>
<data name="Author" xml:space="preserve">
<value>Autore</value>
</data>
<data name="Back" xml:space="preserve">
<value>Indietro</value>
</data>
<data name="Bookmark" xml:space="preserve">
<value>Segnalibro</value>
</data>
<data name="Bookmarks" xml:space="preserve">
<value>Segnalibri</value>
</data>
<data name="Choose a persona first." xml:space="preserve">
<value>Scegli prima una persona.</value>
</data>
<data name="Content warning" xml:space="preserve">
<value>Avviso sul contenuto</value>
</data>
<data name="Counts" xml:space="preserve">
<value>Conteggi</value>
</data>
<data name="Each persona has its own token; PrivaPub never tells anyone they share a login." xml:space="preserve">
<value>Ogni persona ha il proprio token; PrivaPub non dice mai a nessuno che condividono un accesso.</value>
</data>
<data name="Event" xml:space="preserve">
<value>Evento</value>
</data>
<data name="Favourites" xml:space="preserve">
<value>Preferiti</value>
</data>
<data name="Federated" xml:space="preserve">
<value>Federata</value>
</data>
<data name="Feed" xml:space="preserve">
<value>Feed</value>
</data>
<data name="Globe" xml:space="preserve">
<value>Globo</value>
</data>
<data name="Globe of the posts in view" xml:space="preserve">
<value>Globo dei post visibili</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Gruppi</value>
</data>
<data name="Located" xml:space="preserve">
<value>Localizzato</value>
</data>
<data name="Located post" xml:space="preserve">
<value>Post localizzato</value>
</data>
<data name="Located post published." xml:space="preserve">
<value>Post localizzato pubblicato.</value>
</data>
<data name="Media" xml:space="preserve">
<value>Media</value>
</data>
<data name="Near you" xml:space="preserve">
<value>Vicino a te</value>
</data>
<data name="Nearby" xml:space="preserve">
<value>Vicini</value>
</data>
<data name="Nearby shows located posts written within their range of you. It needs this device&#x27;s position, which goes to PrivaPub only." xml:space="preserve">
<value>Vicini mostra i post localizzati scritti entro il loro raggio da te. Serve la posizione di questo dispositivo, che va solo a PrivaPub.</value>
</data>
<data name="No persona" xml:space="preserve">
<value>Nessuna persona</value>
</data>
<data name="No persona yet" xml:space="preserve">
<value>Ancora nessuna persona</value>
</data>
<data name="Notifications" xml:space="preserve">
<value>Notifiche</value>
</data>
<data name="One login for all of your personas: each one is signed in separately and never linked to the others." xml:space="preserve">
<value>Un solo accesso per tutte le tue persone: ognuna entra separatamente e non viene mai collegata alle altre.</value>
</data>
<data name="Open original" xml:space="preserve">
<value>Apri l'originale</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Place" xml:space="preserve">
<value>Luogo</value>
</data>
<data name="Published." xml:space="preserve">
<value>Pubblicato.</value>
</data>
<data name="Reply to {0}" xml:space="preserve">
<value>Rispondi a {0}</value>
</data>
<data name="Retry" xml:space="preserve">
<value>Riprova</value>
</data>
<data name="Sensitive media, show" xml:space="preserve">
<value>Media sensibili, mostra</value>
</data>
<data name="Shown only to people within {0} km of here, never sent to other servers." xml:space="preserve">
<value>Visibile solo a chi è entro {0} km da qui, mai inviato ad altri server.</value>
</data>
<data name="Sign in to PrivaPub" xml:space="preserve">
<value>Accedi a PrivaPub</value>
</data>
<data name="Switch persona" xml:space="preserve">
<value>Cambia persona</value>
</data>
<data name="Tag" xml:space="preserve">
<value>Tag</value>
</data>
<data name="That&#x27;s all." xml:space="preserve">
<value>È tutto.</value>
</data>
<data name="The feed could not be read." xml:space="preserve">
<value>Non è stato possibile leggere il feed.</value>
</data>
<data name="The globe could not start." xml:space="preserve">
<value>Il globo non è riuscito a partire.</value>
</data>
<data name="The server is behind {0}, so its place is unknown." xml:space="preserve">
<value>Il server è dietro {0}, quindi la sua posizione è sconosciuta.</value>
</data>
<data name="This device did not give its position: {0}" xml:space="preserve">
<value>Questo dispositivo non ha dato la sua posizione: {0}</value>
</data>
<data name="This login has no persona to act as. Create one on PrivaPub first." xml:space="preserve">
<value>Questo accesso non ha una persona con cui agire. Creane prima una su PrivaPub.</value>
</data>
<data name="This persona is in no community or circle yet." xml:space="preserve">
<value>Questa persona non è ancora in nessuna comunità o cerchia.</value>
</data>
<data name="Thread" xml:space="preserve">
<value>Discussione</value>
</data>
<data name="Use my position" xml:space="preserve">
<value>Usa la mia posizione</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nome utente</value>
</data>
<data name="What&#x27;s happening?" xml:space="preserve">
<value>Cosa succede?</value>
</data>
<data name="Who are you today?" xml:space="preserve">
<value>Chi sei oggi?</value>
</data>
<data name="active this month" xml:space="preserve">
<value>attivi questo mese</value>
</data>
<data name="ago" xml:space="preserve">
<value>fa</value>
</data>
<data name="answer" xml:space="preserve">
<value>risposta</value>
</data>
<data name="auto-rotate" xml:space="preserve">
<value>rotazione automatica</value>
</data>
<data name="boosts" xml:space="preserve">
<value>condivisioni</value>
</data>
<data name="boosts per favourite" xml:space="preserve">
<value>condivisioni per preferito</value>
</data>
<data name="camera" xml:space="preserve">
<value>camera</value>
</data>
<data name="centre of view" xml:space="preserve">
<value>centro della vista</value>
</data>
<data name="closed" xml:space="preserve">
<value>chiuse</value>
</data>
<data name="delivery latency" xml:space="preserve">
<value>latenza di consegna</value>
</data>
<data name="described" xml:space="preserve">
<value>descritto</value>
</data>
<data name="end" xml:space="preserve">
<value>fine</value>
</data>
<data name="error" xml:space="preserve">
<value>errore</value>
</data>
<data name="failures in a row" xml:space="preserve">
<value>errori di fila</value>
</data>
<data name="first page" xml:space="preserve">
<value>prima pagina</value>
</data>
<data name="followers" xml:space="preserve">
<value>follower</value>
</data>
<data name="following" xml:space="preserve">
<value>seguiti</value>
</data>
<data name="from PrivaPub" xml:space="preserve">
<value>da PrivaPub</value>
</data>
<data name="in view" xml:space="preserve">
<value>visibili</value>
</data>
<data name="items" xml:space="preserve">
<value>elementi</value>
</data>
<data name="last delivery" xml:space="preserve">
<value>ultima consegna</value>
</data>
<data name="last request" xml:space="preserve">
<value>ultima richiesta</value>
</data>
<data name="local posts" xml:space="preserve">
<value>post locali</value>
</data>
<data name="located at" xml:space="preserve">
<value>localizzato il</value>
</data>
<data name="located by" xml:space="preserve">
<value>localizzato da</value>
</data>
<data name="network" xml:space="preserve">
<value>rete</value>
</data>
<data name="open" xml:space="preserve">
<value>aperte</value>
</data>
<data name="paused" xml:space="preserve">
<value>in pausa</value>
</data>
<data name="points" xml:space="preserve">
<value>punti</value>
</data>
<data name="poll every" xml:space="preserve">
<value>controllo ogni</value>
</data>
<data name="polled" xml:space="preserve">
<value>controllato</value>
</data>
<data name="posts" xml:space="preserve">
<value>post</value>
</data>
<data name="precision" xml:space="preserve">
<value>precisione</value>
</data>
<data name="quotes" xml:space="preserve">
<value>citazioni</value>
</data>
<data name="registrations" xml:space="preserve">
<value>iscrizioni</value>
</data>
<data name="replies" xml:space="preserve">
<value>risposte</value>
</data>
<data name="server" xml:space="preserve">
<value>server</value>
</data>
<data name="servers" xml:space="preserve">
<value>server</value>
</data>
<data name="software" xml:space="preserve">
<value>software</value>
</data>
<data name="source" xml:space="preserve">
<value>fonte</value>
</data>
<data name="spread" xml:space="preserve">
<value>dispersione</value>
</data>
<data name="texture" xml:space="preserve">
<value>texture</value>
</data>
<data name="the author&#x27;s server" xml:space="preserve">
<value>il server dell'autore</value>
</data>
<data name="the author&#x27;s server&#x27;s country" xml:space="preserve">
<value>il paese del server dell'autore</value>
</data>
<data name="the event&#x27;s venue" xml:space="preserve">
<value>il luogo dell'evento</value>
</data>
<data name="the post&#x27;s own place" xml:space="preserve">
<value>il luogo del post</value>
</data>
<data name="votes" xml:space="preserve">
<value>voti</value>
</data>
<data name="waiting" xml:space="preserve">
<value>in attesa</value>
</data>
<data name="{0} asked to follow you" xml:space="preserve">
<value>{0} ha chiesto di seguirti</value>
</data>
<data name="{0} boosted your post" xml:space="preserve">
<value>{0} ha condiviso il tuo post</value>
</data>
<data name="{0} edited a post" xml:space="preserve">
<value>{0} ha modificato un post</value>
</data>
<data name="{0} favourited your post" xml:space="preserve">
<value>{0} ha aggiunto il tuo post ai preferiti</value>
</data>
<data name="{0} followed you" xml:space="preserve">
<value>{0} ha iniziato a seguirti</value>
</data>
<data name="{0} km from you, shown within {1} km" xml:space="preserve">
<value>a {0} km da te, visibile entro {1} km</value>
</data>
<data name="{0} mentioned you" xml:space="preserve">
<value>{0} ti ha menzionato</value>
</data>
<data name="{0} new" xml:space="preserve">
<value>{0} nuovi</value>
</data>
<data name="{0} posted" xml:space="preserve">
<value>{0} ha pubblicato</value>
</data>
<data name="{0} quoted your post" xml:space="preserve">
<value>{0} ha citato il tuo post</value>
</data>
<data name="{0} reacted {1}" xml:space="preserve">
<value>{0} ha reagito {1}</value>
</data>
<data name="{0} votes" xml:space="preserve">
<value>{0} voti</value>
</data>
<data name="{0}: {1}" xml:space="preserve">
<value>{0}: {1}</value>
</data>
<data name="hovered" xml:space="preserve">
<value>sotto il puntatore</value>
</data>
<data name="arcs" xml:space="preserve">
<value>archi</value>
</data>
<data name="rings" xml:space="preserve">
<value>anelli</value>
</data>
<data name="placed" xml:space="preserve">
<value>collocati</value>
</data>
</root> </root>
+354 -33
View File
@@ -50,9 +50,6 @@
<data name="Alternative text" xml:space="preserve"> <data name="Alternative text" xml:space="preserve">
<value>Alternative text</value> <value>Alternative text</value>
</data> </data>
<data name="Ascending" xml:space="preserve">
<value>Ascending</value>
</data>
<data name="Audio" xml:space="preserve"> <data name="Audio" xml:space="preserve">
<value>Audio</value> <value>Audio</value>
</data> </data>
@@ -83,15 +80,9 @@
<data name="Delete" xml:space="preserve"> <data name="Delete" xml:space="preserve">
<value>Delete</value> <value>Delete</value>
</data> </data>
<data name="Descending" xml:space="preserve">
<value>Descending</value>
</data>
<data name="Direct" xml:space="preserve"> <data name="Direct" xml:space="preserve">
<value>Direct</value> <value>Direct</value>
</data> </data>
<data name="Direct message" xml:space="preserve">
<value>Direct message</value>
</data>
<data name="Documents" xml:space="preserve"> <data name="Documents" xml:space="preserve">
<value>Documents</value> <value>Documents</value>
</data> </data>
@@ -128,15 +119,9 @@
<data name="Home" xml:space="preserve"> <data name="Home" xml:space="preserve">
<value>Home</value> <value>Home</value>
</data> </data>
<data name="HTML" xml:space="preserve">
<value>HTML</value>
</data>
<data name="Images" xml:space="preserve"> <data name="Images" xml:space="preserve">
<value>Images</value> <value>Images</value>
</data> </data>
<data name="Index" xml:space="preserve">
<value>Home</value>
</data>
<data name="Instance" xml:space="preserve"> <data name="Instance" xml:space="preserve">
<value>Instance</value> <value>Instance</value>
</data> </data>
@@ -164,9 +149,6 @@
<data name="Logout" xml:space="preserve"> <data name="Logout" xml:space="preserve">
<value>Logout</value> <value>Logout</value>
</data> </data>
<data name="Markdown" xml:space="preserve">
<value>Markdown</value>
</data>
<data name="Message deleted." xml:space="preserve"> <data name="Message deleted." xml:space="preserve">
<value>Message deleted.</value> <value>Message deleted.</value>
</data> </data>
@@ -188,15 +170,9 @@
<data name="Not found" xml:space="preserve"> <data name="Not found" xml:space="preserve">
<value>Not found</value> <value>Not found</value>
</data> </data>
<data name="oh shit... here we go again" xml:space="preserve">
<value>oh shit... here we go again</value>
</data>
<data name="Other" xml:space="preserve"> <data name="Other" xml:space="preserve">
<value>Other</value> <value>Other</value>
</data> </data>
<data name="PlainText" xml:space="preserve">
<value>Plain text</value>
</data>
<data name="Policies" xml:space="preserve"> <data name="Policies" xml:space="preserve">
<value>Policies</value> <value>Policies</value>
</data> </data>
@@ -221,18 +197,12 @@
<data name="Settings" xml:space="preserve"> <data name="Settings" xml:space="preserve">
<value>Settings</value> <value>Settings</value>
</data> </data>
<data name="Show preview" xml:space="preserve">
<value>Show preview</value>
</data>
<data name="Sorry, there's nothing at this address." xml:space="preserve"> <data name="Sorry, there's nothing at this address." xml:space="preserve">
<value>Sorry, there's nothing at this address.</value> <value>Sorry, there's nothing at this address.</value>
</data> </data>
<data name="Supplied file &quot;{0}&quot; with size {1} exceeds the maximum of {2}." xml:space="preserve"> <data name="Supplied file &quot;{0}&quot; with size {1} exceeds the maximum of {2}." xml:space="preserve">
<value>Supplied file "{0}" with size {1} exceeds the maximum of {2}.</value> <value>Supplied file "{0}" with size {1} exceeds the maximum of {2}.</value>
</data> </data>
<data name="The action could not be completed." xml:space="preserve">
<value>The action could not be completed.</value>
</data>
<data name="The file &quot;{0}&quot; could not be read." xml:space="preserve"> <data name="The file &quot;{0}&quot; could not be read." xml:space="preserve">
<value>The file "{0}" could not be read.</value> <value>The file "{0}" could not be read.</value>
</data> </data>
@@ -263,9 +233,6 @@
<data name="This message doesn't exist." xml:space="preserve"> <data name="This message doesn't exist." xml:space="preserve">
<value>This message doesn't exist.</value> <value>This message doesn't exist.</value>
</data> </data>
<data name="Title (optional)" xml:space="preserve">
<value>Title (optional)</value>
</data>
<data name="Unlisted" xml:space="preserve"> <data name="Unlisted" xml:space="preserve">
<value>Unlisted</value> <value>Unlisted</value>
</data> </data>
@@ -320,4 +287,358 @@
<data name="{0}MB" xml:space="preserve"> <data name="{0}MB" xml:space="preserve">
<value>{0}MB</value> <value>{0}MB</value>
</data> </data>
<data name="A located post needs this device&#x27;s position." xml:space="preserve">
<value>A located post needs this device's position.</value>
</data>
<data name="A poll you took part in has ended" xml:space="preserve">
<value>A poll you took part in has ended</value>
</data>
<data name="Asking this device for its position…" xml:space="preserve">
<value>Asking this device for its position…</value>
</data>
<data name="Attach" xml:space="preserve">
<value>Attach</value>
</data>
<data name="Author" xml:space="preserve">
<value>Author</value>
</data>
<data name="Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Bookmark" xml:space="preserve">
<value>Bookmark</value>
</data>
<data name="Bookmarks" xml:space="preserve">
<value>Bookmarks</value>
</data>
<data name="Choose a persona first." xml:space="preserve">
<value>Choose a persona first.</value>
</data>
<data name="Content warning" xml:space="preserve">
<value>Content warning</value>
</data>
<data name="Counts" xml:space="preserve">
<value>Counts</value>
</data>
<data name="Each persona has its own token; PrivaPub never tells anyone they share a login." xml:space="preserve">
<value>Each persona has its own token; PrivaPub never tells anyone they share a login.</value>
</data>
<data name="Event" xml:space="preserve">
<value>Event</value>
</data>
<data name="Favourites" xml:space="preserve">
<value>Favourites</value>
</data>
<data name="Federated" xml:space="preserve">
<value>Federated</value>
</data>
<data name="Feed" xml:space="preserve">
<value>Feed</value>
</data>
<data name="Globe" xml:space="preserve">
<value>Globe</value>
</data>
<data name="Globe of the posts in view" xml:space="preserve">
<value>Globe of the posts in view</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Groups</value>
</data>
<data name="Located" xml:space="preserve">
<value>Located</value>
</data>
<data name="Located post" xml:space="preserve">
<value>Located post</value>
</data>
<data name="Located post published." xml:space="preserve">
<value>Located post published.</value>
</data>
<data name="Media" xml:space="preserve">
<value>Media</value>
</data>
<data name="Near you" xml:space="preserve">
<value>Near you</value>
</data>
<data name="Nearby" xml:space="preserve">
<value>Nearby</value>
</data>
<data name="Nearby shows located posts written within their range of you. It needs this device&#x27;s position, which goes to PrivaPub only." xml:space="preserve">
<value>Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only.</value>
</data>
<data name="No persona" xml:space="preserve">
<value>No persona</value>
</data>
<data name="No persona yet" xml:space="preserve">
<value>No persona yet</value>
</data>
<data name="Notifications" xml:space="preserve">
<value>Notifications</value>
</data>
<data name="One login for all of your personas: each one is signed in separately and never linked to the others." xml:space="preserve">
<value>One login for all of your personas: each one is signed in separately and never linked to the others.</value>
</data>
<data name="Open original" xml:space="preserve">
<value>Open original</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Place" xml:space="preserve">
<value>Place</value>
</data>
<data name="Published." xml:space="preserve">
<value>Published.</value>
</data>
<data name="Reply to {0}" xml:space="preserve">
<value>Reply to {0}</value>
</data>
<data name="Retry" xml:space="preserve">
<value>Retry</value>
</data>
<data name="Sensitive media, show" xml:space="preserve">
<value>Sensitive media, show</value>
</data>
<data name="Shown only to people within {0} km of here, never sent to other servers." xml:space="preserve">
<value>Shown only to people within {0} km of here, never sent to other servers.</value>
</data>
<data name="Sign in to PrivaPub" xml:space="preserve">
<value>Sign in to PrivaPub</value>
</data>
<data name="Switch persona" xml:space="preserve">
<value>Switch persona</value>
</data>
<data name="Tag" xml:space="preserve">
<value>Tag</value>
</data>
<data name="That&#x27;s all." xml:space="preserve">
<value>That's all.</value>
</data>
<data name="The feed could not be read." xml:space="preserve">
<value>The feed could not be read.</value>
</data>
<data name="The globe could not start." xml:space="preserve">
<value>The globe could not start.</value>
</data>
<data name="The server is behind {0}, so its place is unknown." xml:space="preserve">
<value>The server is behind {0}, so its place is unknown.</value>
</data>
<data name="This device did not give its position: {0}" xml:space="preserve">
<value>This device did not give its position: {0}</value>
</data>
<data name="This login has no persona to act as. Create one on PrivaPub first." xml:space="preserve">
<value>This login has no persona to act as. Create one on PrivaPub first.</value>
</data>
<data name="This persona is in no community or circle yet." xml:space="preserve">
<value>This persona is in no community or circle yet.</value>
</data>
<data name="Thread" xml:space="preserve">
<value>Thread</value>
</data>
<data name="Use my position" xml:space="preserve">
<value>Use my position</value>
</data>
<data name="Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="What&#x27;s happening?" xml:space="preserve">
<value>What's happening?</value>
</data>
<data name="Who are you today?" xml:space="preserve">
<value>Who are you today?</value>
</data>
<data name="active this month" xml:space="preserve">
<value>active this month</value>
</data>
<data name="ago" xml:space="preserve">
<value>ago</value>
</data>
<data name="answer" xml:space="preserve">
<value>answer</value>
</data>
<data name="auto-rotate" xml:space="preserve">
<value>auto-rotate</value>
</data>
<data name="boosts" xml:space="preserve">
<value>boosts</value>
</data>
<data name="boosts per favourite" xml:space="preserve">
<value>boosts per favourite</value>
</data>
<data name="camera" xml:space="preserve">
<value>camera</value>
</data>
<data name="centre of view" xml:space="preserve">
<value>centre of view</value>
</data>
<data name="closed" xml:space="preserve">
<value>closed</value>
</data>
<data name="delivery latency" xml:space="preserve">
<value>delivery latency</value>
</data>
<data name="described" xml:space="preserve">
<value>described</value>
</data>
<data name="end" xml:space="preserve">
<value>end</value>
</data>
<data name="error" xml:space="preserve">
<value>error</value>
</data>
<data name="failures in a row" xml:space="preserve">
<value>failures in a row</value>
</data>
<data name="first page" xml:space="preserve">
<value>first page</value>
</data>
<data name="followers" xml:space="preserve">
<value>followers</value>
</data>
<data name="following" xml:space="preserve">
<value>following</value>
</data>
<data name="from PrivaPub" xml:space="preserve">
<value>from PrivaPub</value>
</data>
<data name="in view" xml:space="preserve">
<value>in view</value>
</data>
<data name="items" xml:space="preserve">
<value>items</value>
</data>
<data name="last delivery" xml:space="preserve">
<value>last delivery</value>
</data>
<data name="last request" xml:space="preserve">
<value>last request</value>
</data>
<data name="local posts" xml:space="preserve">
<value>local posts</value>
</data>
<data name="located at" xml:space="preserve">
<value>located at</value>
</data>
<data name="located by" xml:space="preserve">
<value>located by</value>
</data>
<data name="network" xml:space="preserve">
<value>network</value>
</data>
<data name="open" xml:space="preserve">
<value>open</value>
</data>
<data name="paused" xml:space="preserve">
<value>paused</value>
</data>
<data name="points" xml:space="preserve">
<value>points</value>
</data>
<data name="poll every" xml:space="preserve">
<value>poll every</value>
</data>
<data name="polled" xml:space="preserve">
<value>polled</value>
</data>
<data name="posts" xml:space="preserve">
<value>posts</value>
</data>
<data name="precision" xml:space="preserve">
<value>precision</value>
</data>
<data name="quotes" xml:space="preserve">
<value>quotes</value>
</data>
<data name="registrations" xml:space="preserve">
<value>registrations</value>
</data>
<data name="replies" xml:space="preserve">
<value>replies</value>
</data>
<data name="server" xml:space="preserve">
<value>server</value>
</data>
<data name="servers" xml:space="preserve">
<value>servers</value>
</data>
<data name="software" xml:space="preserve">
<value>software</value>
</data>
<data name="source" xml:space="preserve">
<value>source</value>
</data>
<data name="spread" xml:space="preserve">
<value>spread</value>
</data>
<data name="texture" xml:space="preserve">
<value>texture</value>
</data>
<data name="the author&#x27;s server" xml:space="preserve">
<value>the author's server</value>
</data>
<data name="the author&#x27;s server&#x27;s country" xml:space="preserve">
<value>the author's server's country</value>
</data>
<data name="the event&#x27;s venue" xml:space="preserve">
<value>the event's venue</value>
</data>
<data name="the post&#x27;s own place" xml:space="preserve">
<value>the post's own place</value>
</data>
<data name="votes" xml:space="preserve">
<value>votes</value>
</data>
<data name="waiting" xml:space="preserve">
<value>waiting</value>
</data>
<data name="{0} asked to follow you" xml:space="preserve">
<value>{0} asked to follow you</value>
</data>
<data name="{0} boosted your post" xml:space="preserve">
<value>{0} boosted your post</value>
</data>
<data name="{0} edited a post" xml:space="preserve">
<value>{0} edited a post</value>
</data>
<data name="{0} favourited your post" xml:space="preserve">
<value>{0} favourited your post</value>
</data>
<data name="{0} followed you" xml:space="preserve">
<value>{0} followed you</value>
</data>
<data name="{0} km from you, shown within {1} km" xml:space="preserve">
<value>{0} km from you, shown within {1} km</value>
</data>
<data name="{0} mentioned you" xml:space="preserve">
<value>{0} mentioned you</value>
</data>
<data name="{0} new" xml:space="preserve">
<value>{0} new</value>
</data>
<data name="{0} posted" xml:space="preserve">
<value>{0} posted</value>
</data>
<data name="{0} quoted your post" xml:space="preserve">
<value>{0} quoted your post</value>
</data>
<data name="{0} reacted {1}" xml:space="preserve">
<value>{0} reacted {1}</value>
</data>
<data name="{0} votes" xml:space="preserve">
<value>{0} votes</value>
</data>
<data name="{0}: {1}" xml:space="preserve">
<value>{0}: {1}</value>
</data>
<data name="hovered" xml:space="preserve">
<value>hovered</value>
</data>
<data name="arcs" xml:space="preserve">
<value>arcs</value>
</data>
<data name="rings" xml:space="preserve">
<value>rings</value>
</data>
<data name="placed" xml:space="preserve">
<value>placed</value>
</data>
</root> </root>
+139
View File
@@ -0,0 +1,139 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient.Services
{
/// <summary>
/// One sign-in for both of PrivaPub's APIs: the root signs in on /clientapi, then each persona it uses gets its own
/// Mastodon token in exchange for the JWT (PrivaPub's PersonaExchange), so the Mastodon API never learns the root.
/// Results are WebResults, lists come back empty on failure; nothing throws.
/// </summary>
public class AuthService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AppConfiguration appConfiguration,
ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary>
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (string.IsNullOrEmpty(jwtUser?.Token))
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
await authStateProvider.SetSession(jwtUser, cancellationToken);
return new WebResult { Data = jwtUser };
}
/// <summary>The root's personas.</summary>
public async Task<List<ViewAvatar>> Personas(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Personas, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Personas), cancellationToken);
return [];
}
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.PersonaTokens.ContainsKey(personaId))
{
await authStateProvider.SetPersona(personaId, cancellationToken: cancellationToken);
return new();
}
using var response = await httpService.ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The current persona's Mastodon account, or null.</summary>
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(CurrentAccount), cancellationToken);
return null;
}
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
}
/// <summary>Renews the JWT when it is about to expire; persona tokens do not expire and are kept.</summary>
public async Task RefreshIfNeeded(CancellationToken cancellationToken = default)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.TokenExpiration is not { } expiration)
return;
var lifetime = session.TokenIssuedAt is { } issued ? TimeSpan.FromTicks(expiration - issued) : RenewBefore * 4;
var renewBefore = lifetime / 4 < RenewBefore ? lifetime / 4 : RenewBefore;
if (new DateTime(expiration, DateTimeKind.Utc) - DateTime.UtcNow > renewBefore)
return;
if (await authStateProvider.GetToken(cancellationToken) is null)
return;
using var response = await httpService.Get(APIs.ClientApi.GET_SniffAgain, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(RefreshIfNeeded), cancellationToken);
return;
}
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (!string.IsNullOrEmpty(jwtUser?.Token))
await authStateProvider.SetSession(jwtUser, cancellationToken);
}
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
public async Task Logout(CancellationToken cancellationToken = default)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
foreach (var token in session.PersonaTokens.Values)
{
using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary<string, string>
{
["token"] = token,
["client_id"] = appConfiguration.ClientId
}, cancellationToken: cancellationToken);
if (!revoked.IsSuccessStatusCode)
await LogFailure(revoked, nameof(Logout), cancellationToken);
}
if (await authStateProvider.GetToken(cancellationToken) is not null)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Logout, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
await LogFailure(response, nameof(Logout), cancellationToken);
}
await authStateProvider.LogoutAsync(deleteDb: true, cancellationToken);
}
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)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{caller}", cancellationToken);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels.Group;
namespace decePubClient.Services
{
/// <summary>The communities and circles a persona belongs to (/clientapi/group/list).</summary>
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The persona's groups, communities first; empty on failure.</summary>
public async Task<List<ViewGroup>> List(string personaId, CancellationToken cancellationToken = default)
{
if (personaId is null)
return [];
using var response = await httpService.Get(APIs.ClientApi.GET_Groups, [$"avatarId={Uri.EscapeDataString(personaId)}"],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(GroupService)}.{nameof(List)}", cancellationToken);
return [];
}
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
}
}
}
+154 -23
View File
@@ -1,3 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -5,16 +7,22 @@ using collAnon.Client.Services;
using decePubClient.Helpers; using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary> /// <summary>
/// Requests to the PrivaPub API through the "default" HttpClient (AppConfiguration.ApiBaseAddress), at three /// Requests to PrivaPub through the "default" HttpClient (AppConfiguration.ApiBaseAddress), at three authentication
/// authentication levels: Get/Post/Delete need a valid token and sign out on 401, *Anon add the token when there is /// levels: Get/Post/Delete need credentials and sign out when PrivaPub refuses them, *Anon add them when there are
/// one, *TotallyAnon never send it. Nothing throws: a failure comes back as a response carrying an invalid WebResult /// some, *TotallyAnon never send them. The route picks the credentials: clientapi/ the root's JWT, api/ the current
/// (SUtility.ClassifiedFailure, read with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. /// persona's Mastodon token (exchanged from the JWT when missing, and once more when PrivaPub refuses it), oauth/
/// With retryOnError, 408/429/502/503/504 and transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then /// none. It also picks the JSON: snake_case for api/ and oauth/ (SUtility.MastodonSerializer), camelCase for
/// every 60, plus jitter, for at most 10 minutes. /// clientapi/. A payload can be an object (JSON), name/value pairs (a form) or a Func&lt;HttpContent&gt; (e.g. multipart),
/// called once per attempt.
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
/// minutes. Every request PrivaPub answers is recorded in RequestStats.
/// </summary> /// </summary>
public interface IHttpService public interface IHttpService
{ {
@@ -37,10 +45,16 @@ namespace decePubClient.Services
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false, Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Exchanges the root's JWT for the persona's Mastodon token (PrivaPub's PersonaExchange) and makes the persona
/// current. A failure comes back like any other.
/// </summary>
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
} }
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService, public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
ILoggingService logger, CoalescingStringLocalizer localizer) : IHttpService AppConfiguration appConfiguration, RequestStats requestStats, ILoggingService logger, CoalescingStringLocalizer localizer) : IHttpService
{ {
static readonly int[] RetryRampUpDelaysMs = [1_000, 2_000, 4_000, 8_000, 16_000, 29_000]; static readonly int[] RetryRampUpDelaysMs = [1_000, 2_000, 4_000, 8_000, 16_000, 29_000];
const int RetryIndefiniteDelayMs = 60_000; const int RetryIndefiniteDelayMs = 60_000;
@@ -58,7 +72,7 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false, public Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Send(nameof(GetTotallyAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), httpClient.SendAsync, retryOnError, Send(nameof(GetTotallyAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAnonymousRequest, retryOnError,
cancellationToken); cancellationToken);
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
@@ -70,7 +84,7 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false, public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), httpClient.SendAsync, retryOnError, Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
cancellationToken); cancellationToken);
public Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false, public Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
@@ -79,27 +93,52 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false, public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), httpClient.SendAsync, retryOnError, Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAnonymousRequest, retryOnError,
cancellationToken); cancellationToken);
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var jwt = await authStateProvider.GetToken(cancellationToken);
if (jwt is null)
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
{
["grant_type"] = APIs.OAuth.TokenExchangeGrant,
["client_id"] = appConfiguration.ClientId,
["subject_token"] = jwt,
["subject_token_type"] = APIs.OAuth.JwtTokenType,
["avatar_id"] = personaId,
["scope"] = APIs.OAuth.Scopes
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return response;
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
if (string.IsNullOrEmpty(token?.AccessToken))
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway);
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken);
return response;
}
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory, async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken) Func<Func<HttpRequestMessage>, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken)
{ {
var deadline = DateTime.UtcNow + RetryDeadline; var deadline = DateTime.UtcNow + RetryDeadline;
var attempt = 0; var attempt = 0;
while (true) while (true)
{ {
// A request message can be sent once, so every attempt builds its own. var where = $"{nameof(HttpService)}.{caller}";
var request = requestFactory();
var where = $"{nameof(HttpService)}.{caller}:{request.RequestUri?.OriginalString}";
var failure = default(string); var failure = default(string);
var transportException = default(Exception); var transportException = default(Exception);
try try
{ {
var response = await send(request, cancellationToken); // A request message can be sent once, so every attempt (and every re-authentication) builds its own.
var response = await send(requestFactory, cancellationToken);
var status = (int)response.StatusCode; var status = (int)response.StatusCode;
if (!retryOnError || !RetryableStatusCodes.Contains(status)) if (!retryOnError || !RetryableStatusCodes.Contains(status))
return status > 499 ? ServerError(response) : response; return status > 499 && !response.Content.Headers.Contains(SUtility.FailureHeader) ? ServerError(response) : response;
failure = $"HTTP {status}"; failure = $"HTTP {status}";
response.Dispose(); response.Dispose();
@@ -177,8 +216,15 @@ namespace decePubClient.Services
localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable); localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable);
} }
async Task<HttpResponseMessage> SendAuthRequest(HttpRequestMessage request, CancellationToken cancellationToken) Task<HttpResponseMessage> SendAnonymousRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken) =>
Measure(requestFactory(), cancellationToken);
async Task<HttpResponseMessage> SendAuthRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{ {
var request = requestFactory();
if (IsMastodon(request))
return await SendAsPersona(request, requestFactory, cancellationToken);
var token = await authStateProvider.GetToken(cancellationToken); var token = await authStateProvider.GetToken(cancellationToken);
if (token is null) if (token is null)
{ {
@@ -187,7 +233,7 @@ namespace decePubClient.Services
} }
request.Headers.Authorization = new("Bearer", token); request.Headers.Authorization = new("Bearer", token);
var response = await httpClient.SendAsync(request, cancellationToken); var response = await Measure(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized) if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response; return response;
@@ -196,22 +242,107 @@ namespace decePubClient.Services
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]); return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
} }
async Task<HttpResponseMessage> SendAuthIfAvailableRequest(HttpRequestMessage request, CancellationToken cancellationToken) /// <summary>
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
/// session ends.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{ {
var token = await authStateProvider.GetToken(cancellationToken); var session = await authStateProvider.GetAuthData(cancellationToken);
var token = await authStateProvider.GetPersonaToken(cancellationToken) ?? await Exchange(session.PersonaId, cancellationToken);
if (token is null)
return await EndSession(cancellationToken);
request.Headers.Authorization = new("Bearer", token);
var response = await Measure(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
token = await Exchange(session.PersonaId, cancellationToken);
if (token is null)
return await EndSession(cancellationToken);
var retry = requestFactory();
retry.Headers.Authorization = new("Bearer", token);
response = await Measure(retry, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
response.Dispose();
return await EndSession(cancellationToken);
}
async Task<string> Exchange(string personaId, CancellationToken cancellationToken)
{
if (personaId is null)
return null;
using var response = await ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? await authStateProvider.GetPersonaToken(cancellationToken) : null;
}
async Task<HttpResponseMessage> EndSession(CancellationToken cancellationToken)
{
if (await authStateProvider.GetToken(cancellationToken) is null)
await authStateProvider.LogoutAsync(cancellationToken: cancellationToken);
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
}
async Task<HttpResponseMessage> SendAuthIfAvailableRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var request = requestFactory();
var token = IsMastodon(request) ? await authStateProvider.GetPersonaToken(cancellationToken) : await authStateProvider.GetToken(cancellationToken);
if (token is not null) if (token is not null)
request.Headers.Authorization = new("Bearer", token); request.Headers.Authorization = new("Bearer", token);
return await httpClient.SendAsync(request, cancellationToken); return await Measure(request, cancellationToken);
} }
/// <summary>Sends the request and records it in RequestStats.</summary>
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
var response = await httpClient.SendAsync(request, cancellationToken);
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
return response;
}
static int? HeaderInt(HttpResponseMessage response, string name) =>
response.Headers.TryGetValues(name, out var values) && int.TryParse(values.FirstOrDefault(), out var number) ? number : null;
static DateTime? HeaderTime(HttpResponseMessage response, string name)
{
if (!response.Headers.TryGetValues(name, out var values))
return null;
var value = values.FirstOrDefault();
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var time))
return time;
return long.TryParse(value, out var seconds) ? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime : null;
}
static bool IsMastodon(HttpRequestMessage request) =>
request.RequestUri?.OriginalString.StartsWith(APIs.MastodonPrefix, StringComparison.Ordinal) == true;
static HttpRequestMessage BuildRequest(HttpMethod method, string uri, object payload = default, string[] queryParams = default, static HttpRequestMessage BuildRequest(HttpMethod method, string uri, object payload = default, string[] queryParams = default,
bool forceContent = false) bool forceContent = false)
{ {
var target = queryParams is { Length: > 0 } ? $"{uri}?{string.Join('&', queryParams)}" : uri; var target = queryParams is { Length: > 0 } ? $"{uri}?{string.Join('&', queryParams)}" : uri;
var request = new HttpRequestMessage(method, target); var request = new HttpRequestMessage(method, target);
if (payload is not null || forceContent) request.Content = payload switch
request.Content = JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SUtility.DefaultSerializer); {
Func<HttpContent> content => content(),
IEnumerable<KeyValuePair<string, string>> fields => new FormUrlEncodedContent(fields),
null when !forceContent => null,
_ => JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SerializerFor(uri))
};
return request; return request;
} }
/// <summary>snake_case for the Mastodon API and /oauth, camelCase for /clientapi.</summary>
static System.Text.Json.JsonSerializerOptions SerializerFor(string uri) =>
uri.StartsWith(APIs.ClientApiPrefix, StringComparison.Ordinal) ? SUtility.DefaultSerializer : SUtility.MastodonSerializer;
} }
} }
+7 -71
View File
@@ -1,4 +1,4 @@
using decePubClient.Helpers; using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
@@ -6,22 +6,12 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary> /// <summary>
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): messages and the client log. Lists /// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): the client log. Lists come back empty on
/// come back empty on failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger /// failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger only, because
/// only, because ILoggingService writes its log through this class. /// ILoggingService writes its log through this class.
/// </summary> /// </summary>
public interface IStorage public interface IStorage
{ {
Task<List<Message>> GetMessages(CancellationToken cancellationToken = default);
Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default);
Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default);
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default); Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default); Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
@@ -36,59 +26,6 @@ namespace decePubClient.Services
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary> /// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
const int MaxLogs = 100; const int MaxLogs = 100;
#region Messages
public async Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var message = await db.GetByKey<string, Message>(nameof(Message), messageId);
if (message is not null)
return message;
var messages = await GetMessages(cancellationToken);
return messages.FirstOrDefault(m => m.MessageId == messageId);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessage)}");
return default;
}
}
/// <summary>The stored messages, newest first; while none are stored, the mock feed (Faker.SeedMessages).</summary>
public async Task<List<Message>> GetMessages(CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var messages = await db.GetAll<Message>(nameof(Message)) ?? [];
if (messages.Count == 0)
messages = Faker.SeedMessages();
return [.. messages.OrderByDescending(m => m.CreatedAt)];
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessages)}");
return [];
}
}
public Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(AddMessages), async () => await db.AddItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(UpdateMessages), async () => await db.UpdateItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveMessage), async () => await db.DeleteByKey(nameof(Message), messageId), cancellationToken);
#endregion
#region Logs #region Logs
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default) public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
@@ -156,10 +93,9 @@ namespace decePubClient.Services
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) => public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveAll), async () => Run(nameof(RemoveAll), async () =>
{ {
var result = await db.DeleteAll(nameof(Message)); if (!includeClientLogs)
if (includeClientLogs) return "nothing to remove";
result = await db.DeleteAll(nameof(ClientLogs)); return await db.DeleteAll(nameof(ClientLogs));
return result;
}, cancellationToken); }, cancellationToken);
/// <summary>Runs a write and turns its outcome into a WebResult.</summary> /// <summary>Runs a write and turns its outcome into a WebResult.</summary>
+177
View File
@@ -0,0 +1,177 @@
using System.Text.Json.Nodes;
using Blazored.LocalStorage;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
/// point in the server's country when PrivaPub publishes only that. A CDN-fronted server has no place.
/// </summary>
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
{
const int MaxHostsPerRead = 40;
const string StorageKey = "ServerInfos";
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
bool loaded;
string selfHost;
/// <summary>
/// PrivaPub's own host, the point every arc starts from: the host of the signed-in persona's account (CascadingState
/// sets it), since the API may be reached under another name; the API's host until then.
/// </summary>
public string SelfHost
{
get => selfHost ?? appConfiguration.ApiHost;
set => selfHost = value;
}
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
{
await Resolve([SelfHost], cancellationToken);
return Known(SelfHost);
}
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
{
await Load(cancellationToken);
var now = DateTime.UtcNow;
var wanted = hosts.Where(host => host is { Length: > 0 }).Distinct(StringComparer.OrdinalIgnoreCase)
.Where(host => !servers.TryGetValue(host, out var server) || now - server.FetchedAt > (server.Unknown ? UnknownLifetime : Lifetime))
.ToList();
if (wanted.Count == 0)
return;
foreach (var chunk in wanted.Chunk(MaxHostsPerRead))
{
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_Instances, [.. chunk.Select(host => $"host[]={Uri.EscapeDataString(host)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
continue;
var described = await response.MastodonReadFromJsonAsync<List<InstanceDescription>>(cancellationToken) ?? [];
foreach (var host in chunk)
servers[host] = described.FirstOrDefault(d => string.Equals(d.Host, host, StringComparison.OrdinalIgnoreCase)) is { } description
? ToServerInfo(description, now)
: new() { Host = host, FetchedAt = now, Unknown = true };
}
await Save(cancellationToken);
}
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
{
foreach (var post in posts)
post.Location = Locate(post, nearYou);
}
FeedLocation Locate(FeedPost post, (double Latitude, double Longitude)? nearYou)
{
if (post.Status?.Privapub?.Place is { Latitude: { } placeLatitude, Longitude: { } placeLongitude } place)
return new(placeLatitude, placeLongitude, LocationSource.Place, JoinLabel(place.Name, place.Country), 1);
if (post.Status?.Privapub?.Event?.Places.FirstOrDefault(p => p.Latitude is not null && p.Longitude is not null) is { } venue)
return new(venue.Latitude!.Value, venue.Longitude!.Value, LocationSource.Event, venue.Name ?? venue.Address, 1);
if (post.Located && nearYou is { } reader)
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
var geo = Known(post.Host)?.Geo;
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
if (CountryPoints.Of(geo?.Country) is { } country)
return new(country.Latitude, country.Longitude, LocationSource.Country, CountryName(geo.Country), 500);
return null;
}
/// <summary>
/// The country's English name, or the code when .NET does not know it. Not DisplayName: in the browser it falls back
/// to the country's own language ("日本").
/// </summary>
public static string CountryName(string countryCode)
{
if (countryCode is not { Length: 2 })
return countryCode;
try
{
return new System.Globalization.RegionInfo(countryCode).EnglishName;
}
catch (ArgumentException)
{
return countryCode;
}
}
static string JoinLabel(params string[] parts) => string.Join(", ", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
static ServerInfo ToServerInfo(InstanceDescription description, DateTime fetchedAt) => new()
{
Host = description.Host,
Software = description.Software,
Version = description.Version,
NodeName = description.NodeName,
OpenRegistrations = description.OpenRegistrations,
Users = Number(description.NodeInfo, "usage", "users", "total"),
ActiveMonth = Number(description.NodeInfo, "usage", "users", "activeMonth"),
LocalPosts = Number(description.NodeInfo, "usage", "localPosts"),
Geo = description.Geo,
ConsecutiveFailures = description.Delivery?.ConsecutiveFailures ?? 0,
LastSuccessAt = description.Delivery?.LastSuccessAt,
LastFailureAt = description.Delivery?.LastFailureAt,
DescribedAt = description.DescribedAt,
DescriptionError = description.DescriptionError,
FetchedAt = fetchedAt
};
static long? Number(JsonNode node, params string[] path)
{
foreach (var step in path)
node = node is JsonObject obj ? obj[step] : null;
return node is JsonValue value && value.TryGetValue<long>(out var number) ? number : null;
}
async Task Load(CancellationToken cancellationToken)
{
if (loaded)
return;
loaded = true;
try
{
foreach (var server in await localStorage.GetItemAsync<List<ServerInfo>>(StorageKey, cancellationToken) ?? [])
servers.TryAdd(server.Host, server);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Load)}", cancellationToken);
}
}
async Task Save(CancellationToken cancellationToken)
{
try
{
var fresh = servers.Values.Where(server => DateTime.UtcNow - server.FetchedAt <= Lifetime).ToList();
await localStorage.SetItemAsync(StorageKey, fresh, cancellationToken);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Save)}", cancellationToken);
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Net;
using System.Net.Http.Headers;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{
var file = new ByteArrayContent(media.Blob);
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description");
return content;
}), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = attachment };
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>How a post reached PrivaPub (/api/privapub/v1/statuses/{id}/provenance), read on demand and kept for the session.</summary>
public class ProvenanceService(IHttpService httpService)
{
readonly Dictionary<string, Provenance> provenances = [];
public Provenance Known(string statusId) => statusId is not null ? provenances.GetValueOrDefault(statusId) : null;
/// <summary>The post's provenance, or null when PrivaPub has none to show.</summary>
public async Task<Provenance> Get(string statusId, CancellationToken cancellationToken = default)
{
if (Known(statusId) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Provenance(statusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var provenance = await response.MastodonReadFromJsonAsync<Provenance>(cancellationToken);
if (provenance is not null)
provenances[statusId] = provenance;
return provenance;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Text.RegularExpressions;
namespace decePubClient.Services
{
/// <summary>One request HttpService sent, for the nerd stats.</summary>
public sealed record RequestStat(DateTime At, string Method, string Route, int Status, double DurationMs, long? Bytes,
int? RateLimitRemaining, DateTime? RateLimitReset);
/// <summary>
/// The last requests HttpService sent (a ring of Capacity entries), with route templates instead of ids. It holds no
/// bodies and no tokens: method, route, status, duration, size and PrivaPub's rate limit headers only.
/// </summary>
public partial class RequestStats
{
public const int Capacity = 200;
readonly RequestStat[] ring = new RequestStat[Capacity];
int next;
int count;
public event Action Recorded;
public int Count => count;
public void Record(RequestStat stat)
{
lock (ring)
{
ring[next] = stat;
next = (next + 1) % Capacity;
count = Math.Min(count + 1, Capacity);
}
Recorded?.Invoke();
}
/// <summary>The recorded requests, newest first.</summary>
public List<RequestStat> Latest(int take = Capacity)
{
lock (ring)
{
var latest = new List<RequestStat>(Math.Min(take, count));
for (var i = 1; i <= Math.Min(take, count); i++)
latest.Add(ring[(next - i + Capacity) % Capacity]);
return latest;
}
}
/// <summary>The newest request to a route template, or null.</summary>
public RequestStat LastOf(string route) => Latest().FirstOrDefault(stat => stat.Route == route);
/// <summary>The mean duration of the successful requests recorded, in milliseconds.</summary>
public double? AverageMs()
{
var answered = Latest().Where(stat => stat.Status is >= 200 and < 400).ToList();
return answered.Count == 0 ? null : answered.Average(stat => stat.DurationMs);
}
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
public static string Template(string uri)
{
var path = uri?.Split('?', 2)[0] ?? string.Empty;
if (Uri.TryCreate(path, UriKind.Absolute, out var absolute))
path = absolute.AbsolutePath.TrimStart('/');
return Id().Replace(path, "/:id$1");
}
[GeneratedRegex(@"/(?:[0-9a-f]{24}|[0-9a-f]{32}|\d{6,}|[0-9A-Za-z_-]{20,})(/|$)")]
private static partial Regex Id();
}
}
+159
View File
@@ -0,0 +1,159 @@
using System.Globalization;
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
namespace decePubClient.Services
{
/// <summary>The Mastodon actions a status has, by their route segment.</summary>
public enum StatusAction
{
Favourite,
Unfavourite,
Reblog,
Unreblog,
Bookmark,
Unbookmark
}
/// <summary>
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
{
if (form.IsLocated || form.GroupId is not null)
return await PublishThroughClientApi(form, personaId, cancellationToken);
var mediaIds = new List<string>();
foreach (var media in form.Media)
{
var uploaded = await mediaService.Upload(media, cancellationToken);
if (uploaded.IsInvalid)
return uploaded;
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
}
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
{
Status = form.Content ?? string.Empty,
MediaIds = mediaIds,
InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return new WebResult { Data = FeedPost.Of(status) };
}
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
{
if (form.IsLocated && (form.Latitude is null || form.Longitude is null))
return new WebResult().Invalidate(localizer["A located post needs this device's position."]);
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPost, new InsertPostForm
{
AvatarId = personaId,
Text = form.Content ?? string.Empty,
GroupId = form.GroupId,
AnsweringToPostId = form.InReplyToId,
HasContentWarning = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.MessageType switch
{
MessageType.FollowersOnly => "followersonly",
MessageType.Unlisted => "unlisted",
_ => "public"
},
Latitude = form.IsLocated ? form.Latitude : null,
Longitude = form.IsLocated ? form.Longitude : null,
RangeKm = form.IsLocated ? form.RangeKm : null
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPost>(cancellationToken) };
}
public async Task<WebResult> Act(string statusId, StatusAction action, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_StatusAction(statusId, action.ToString().ToLowerInvariant()),
cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>Deletes the persona's own status.</summary>
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Status(statusId), cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// <summary>The thread around a status, or null when it cannot be read.</summary>
public async Task<StatusContext> Context(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Context(statusId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<StatusContext>(cancellationToken);
await LogFailure(response, nameof(Context), cancellationToken);
return null;
}
/// <summary>Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided).</summary>
public async Task<WebResult> AccountAction(string accountId, string action, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
async Task<WebResult> StatusResult(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return status is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = status };
}
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)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(StatusService)}.{caller}", cancellationToken);
}
}
}
+209
View File
@@ -0,0 +1,209 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
namespace decePubClient.Services
{
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary>
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
double? Latitude = default, double? Longitude = default);
/// <summary>
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
/// from their Link header. A circle's posts come from /clientapi as ids and are read through /api/v1/statuses (a
/// circle post reads as private there); located posts come from /clientapi/post/nearby with their authors read from
/// /api/v1/accounts, and have no next page. Results are WebResults with a FeedPage as Data; nothing throws.
/// </summary>
public class TimelineService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public const int PageSize = 20;
/// <summary>The most ids /api/v1/statuses reads at once.</summary>
const int MaxIdsPerRead = 20;
public async Task<WebResult> Load(FeedQuery query, string maxId = default, string minId = default, CancellationToken cancellationToken = default)
{
try
{
return query.Type switch
{
TimelineType.Home => await Statuses(APIs.Mastodon.GET_HomeTimeline, [], maxId, minId, cancellationToken),
TimelineType.Local => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["local=true"], maxId, minId, cancellationToken),
TimelineType.Federation => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["remote=true"], maxId, minId, cancellationToken),
TimelineType.Favourites => await Statuses(APIs.Mastodon.GET_Favourites, [], maxId, minId, cancellationToken),
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
_ => new WebResult { Data = new FeedPage() }
};
}
catch (Exception ex)
{
await logger.ProcessError(ex, $"{nameof(TimelineService)}.{nameof(Load)}:{query.Type}", cancellationToken);
return new WebResult().Invalidate(localizer["The feed could not be read."], (int)HttpStatusCode.BadGateway, exception: ex);
}
}
async Task<WebResult> Statuses(string route, string[] parameters, string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(route, Paging(parameters, maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. statuses.Select(FeedPost.Of)],
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id,
MinId = prevMinId ?? statuses.FirstOrDefault()?.Id ?? minId
}
};
}
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? [];
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. notifications.Select(FeedPost.Of)],
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
}
};
}
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
async Task<WebResult> Conversations(CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
}
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
async Task<WebResult> Circle(FeedQuery query, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Posts,
[$"avatarId={Uri.EscapeDataString(query.PersonaId)}", $"groupId={Uri.EscapeDataString(query.GroupId)}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
var ids = posts.OrderByDescending(p => p.CreationDate).Select(p => p.Id).Take(PageSize * 2).ToList();
var statuses = await Read(ids, cancellationToken);
return new WebResult { Data = new FeedPage { Posts = [.. ids.Select(id => statuses.GetValueOrDefault(id)).Where(s => s is not null).Select(FeedPost.Of)] } };
}
/// <summary>Located posts within their range of the reader, nearest first, with their authors.</summary>
async Task<WebResult> Nearby(FeedQuery query, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Nearby,
[
$"avatarId={Uri.EscapeDataString(query.PersonaId)}",
$"latitude={query.Latitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
$"longitude={query.Longitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
var authors = new Dictionary<string, Account>();
foreach (var authorId in posts.Select(p => p.AuthorAvatarId).Where(id => id is not null).Distinct())
if (await Account(authorId, cancellationToken) is { } account)
authors[authorId] = account;
return new WebResult
{
Data = new FeedPage
{
Posts =
[
.. posts.OrderBy(p => p.DistanceKm).Select(post => new FeedPost
{
Key = post.Id,
CursorId = post.Id,
Located = true,
DistanceKm = post.DistanceKm,
RangeKm = post.RangeKm,
Status = new()
{
Id = post.Id,
Uri = post.ObjectURI,
Account = authors.GetValueOrDefault(post.AuthorAvatarId ?? string.Empty),
InReplyToId = post.AnsweringToPostId,
Content = post.ContentHtml,
CreatedAt = post.CreationDate,
Sensitive = post.HasContentWarning,
Visibility = post.Visibility,
Privapub = post.Title is { Length: > 0 } ? new() { Title = post.Title } : null
}
})
]
}
};
}
/// <summary>Statuses by id, PrivaPub's batch read (/api/v1/statuses?id[]=), in chunks it accepts.</summary>
public async Task<Dictionary<string, Status>> Read(List<string> ids, CancellationToken cancellationToken = default)
{
var statuses = new Dictionary<string, Status>();
foreach (var chunk in ids.Chunk(MaxIdsPerRead))
{
using var response = await httpService.Get(APIs.Mastodon.GET_Statuses, [.. chunk.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(TimelineService)}.{nameof(Read)}", cancellationToken);
continue;
}
foreach (var status in await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [])
statuses[status.Id] = status;
}
return statuses;
}
async Task<Account> Account(string accountId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<Account>(cancellationToken) : null;
}
static string[] Paging(string[] parameters, string maxId, string minId)
{
var paging = new List<string>(parameters) { $"limit={PageSize}" };
if (maxId is not null)
paging.Add($"max_id={Uri.EscapeDataString(maxId)}");
if (minId is not null)
paging.Add($"min_id={Uri.EscapeDataString(minId)}");
return [.. paging];
}
}
}
+89 -37
View File
@@ -1,50 +1,99 @@
using Blazored.LocalStorage; using Blazored.LocalStorage;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
/// </summary>
public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger) public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
: AuthenticationStateProvider : AuthenticationStateProvider
{ {
public void SetToken(/*string token, long expirationTicks = default*/) /// <summary>The claim naming the persona the client acts as (an avatar id).</summary>
{ public const string PersonaClaim = "persona";
//AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData));
//if (string.IsNullOrEmpty(token))
//{
// Logger.LogInformation($"set null({nameof(SetToken)})");
// AuthData.Token = null;
// AuthData.TokenExpiration = null;
//}
//else
//{
// AuthData.Token = token;
// AuthData.TokenExpiration = expirationTicks;
//}
//await Storage.SetItemAsync(nameof(AuthData), AuthData);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); AuthData authData;
bool loaded;
/// <summary>The stored session, or an empty one.</summary>
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
{
if (loaded)
return authData ?? new();
authData = await storage.GetItemAsync<AuthData>(nameof(AuthData), cancellationToken);
loaded = true;
return authData ?? new();
} }
/// <summary>The stored token while it is still valid, otherwise null.</summary> /// <summary>The root's JWT while it is still valid, otherwise null.</summary>
public async Task<string> GetToken(CancellationToken cancellationToken = default) public async Task<string> GetToken(CancellationToken cancellationToken = default)
{ {
var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData), cancellationToken); var session = await GetAuthData(cancellationToken);
if (string.IsNullOrEmpty(authData?.Token) || authData.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks) if (string.IsNullOrEmpty(session.Token) || session.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
return null; return null;
return authData.Token; return session.Token;
}
/// <summary>The current persona's Mastodon token, or null when the persona has none yet.</summary>
public async Task<string> GetPersonaToken(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (session.PersonaId is null)
return null;
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
} }
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) => public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
await GetToken(cancellationToken) is not null; await GetToken(cancellationToken) is not null;
/// <summary>Starts or renews the session with a JWT from login or sniff/again; persona tokens are kept.</summary>
public async Task SetSession(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.Token = jwtUser.Token;
session.TokenExpiration = jwtUser.Expiration;
session.TokenIssuedAt = DateTime.UtcNow.Ticks;
session.UserName = jwtUser.Username;
session.Policies = jwtUser.Policies ?? [];
if (jwtUser.UserSettings?.LanguageCode is { Length: > 0 } language)
session.CurrentLanguageCode = language;
await Save(session, cancellationToken);
}
/// <summary>Makes the persona current, remembering its token when one is given.</summary>
public async Task SetPersona(string personaId, string token = default, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.PersonaId = personaId;
if (token is not null)
session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken);
}
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
public async Task ForgetPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (!session.PersonaTokens.Remove(personaId))
return;
await Save(session, cancellationToken);
}
public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default) public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default)
{ {
logger.LogInformation($"set null({nameof(LogoutAsync)})"); logger.LogInformation($"set null({nameof(LogoutAsync)})");
//await Storage.RemoveItemAsync(nameof(PrivateCacheData)); authData = null;
loaded = true;
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken); await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
if (deleteDb) if (deleteDb)
await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken); await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
@@ -54,23 +103,26 @@ namespace decePubClient.Services
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData)); var token = await GetToken();
if (string.IsNullOrEmpty(authData?.Token)) if (token is null)
{
logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})");
return new(new()); return new(new());
var session = await GetAuthData();
var claims = new List<Claim> { new(ClaimTypes.Name, session.UserName ?? string.Empty) };
foreach (var policy in new[] { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin })
claims.Add(new(policy, session.Policies.Contains(policy) ? "true" : "false"));
if (session.PersonaId is not null)
claims.Add(new(PersonaClaim, session.PersonaId));
return new(new(new ClaimsIdentity(claims, "jwt")));
} }
var claims = new List<Claim>
async Task Save(AuthData session, CancellationToken cancellationToken)
{ {
new(ClaimTypes.UserData, JsonSerializer.Serialize(authData.User, SUtility.DefaultSerializer)) authData = session;
}; loaded = true;
await storage.SetItemAsync(nameof(AuthData), session, cancellationToken);
//claims.Add(new(Policies.IsUser, (AuthData.User.Policies.Contains(Policies.IsUser)).ToString().ToLower())); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
//claims.Add(new(Policies.UserPlus, (AuthData.User.Policies.Contains(Policies.UserPlus)).ToString().ToLower()));
//claims.Add(new(Policies.IsAdmin, (AuthData.User.Policies.Contains(Policies.IsAdmin)).ToString().ToLower()));
var identity = new ClaimsIdentity(claims, "jwt");
return new(new(identity));
} }
} }
} }
+54 -39
View File
@@ -9,23 +9,33 @@
<div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block"> <div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block">
<div class="flex flex-col gap-3 p-3 md:p-4"> <div class="flex flex-col gap-3 p-3 md:p-4">
<div class="flex gap-3 p-3 md:p-4 neo-raised neo-xs rounded-xl bg-cover bg-no-repeat bg-right" style="background-image:linear-gradient(to left, var(--color-base-100) 55%, transparent), url('@(CascadingState.User.BackgroundUrl)');"> @if (CascadingState.IsSignedIn)
<a class="block h-8 w-8 md:h-12 md:w-12 flex-none" href="@CascadingState.User.ProfileUrl" title="@CascadingState.User.UserName"> {
<img alt="@CascadingState.User.UserName" class="h-8 w-8 md:h-12 md:w-12 object-cover rounded-full neo-raised neo-2xs" src="@(CascadingState.User.PictureUrl ?? "/imgs/icon-192.png")" /> <DDropdown @bind-IsOpen="isPersonaMenuOpen" CloseOnContentClick="true" class="w-full rounded-xl bg-cover bg-no-repeat bg-right"
</a> TriggerClass="flex w-full gap-3 p-3 md:p-4 neo-raised neo-xs rounded-xl text-left cursor-pointer"
<div class="flex flex-col w-full min-w-0 gap-2"> ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box w-full" title="@Localizer["Switch persona"]"
<p class="shrink truncate"> style="@PersonaBackground">
<b title="@CascadingState.User.DisplayName"> <Trigger>
@CascadingState.User.DisplayName <img alt="" class="h-8 w-8 md:h-12 md:w-12 flex-none object-cover rounded-full neo-raised neo-2xs"
</b> src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")" />
</p> <span class="flex flex-col w-full min-w-0 gap-1">
<p class="flex-1 min-w-6 truncate text-xs"> <b class="truncate" title="@CascadingState.Persona?.Name">@(CascadingState.Persona?.Name ?? Localizer["No persona"])</b>
<a class="underline" href="@CascadingState.User.ProfileUrl" title="@CascadingState.User.UserName"> <span class="truncate text-xs opacity-70">@(CascadingState.Persona is { } persona ? "@" + persona.Acct : null)</span>
@CascadingState.User.UserName </span>
</a> <i class="ion-md-swap self-center opacity-60"></i>
</p> </Trigger>
</div> <ChildContent>
</div> @foreach (var persona in CascadingState.Personas)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="persona.Id == CascadingState.PersonaId"
Disabled="IsDefaultDisabled || persona.Id == CascadingState.PersonaId" OnClick="() => SwitchPersona(persona.Id)">
<img alt="" class="h-5 w-5 object-cover rounded-full" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
</DButton>
}
</ChildContent>
</DDropdown>
}
<NavLink ActiveClass="btn-active" class="btn justify-start" href="/" Match="NavLinkMatch.All"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="/" Match="NavLinkMatch.All">
<i class="ion-md-home"></i> <i class="ion-md-home"></i>
@@ -35,34 +45,26 @@
<i class="ion-md-settings"></i> <i class="ion-md-settings"></i>
<span>@Localizer["Settings"]</span> <span>@Localizer["Settings"]</span>
</NavLink> </NavLink>
@*<AuthorizeView> <AuthorizeView Policy="@Policies.IsAdmin">
<Authorized>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
@Localizer["Administration"]
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout">
@Localizer["Logout"]
</NavLink>
</Authorized>
<NotAuthorized>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="login">
@Localizer["Login"]
</NavLink>
</NotAuthorized>
</AuthorizeView>*@
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
<i class="ion-md-switch text-error"></i> <i class="ion-md-switch text-error"></i>
<span>@Localizer["Administration"]</span> <span>@Localizer["Administration"]</span>
</NavLink> </NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="login"> </AuthorizeView>
<i class="ion-md-log-in text-success"></i> @if (CascadingState.IsSignedIn)
<span>@Localizer["Login"]</span> {
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="logout">
<i class="ion-md-log-out text-error"></i> <i class="ion-md-log-out text-error"></i>
<span>@Localizer["Logout"]</span> <span>@Localizer["Logout"]</span>
</NavLink> </NavLink>
}
else
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="login">
<i class="ion-md-log-in text-success"></i>
<span>@Localizer["Login"]</span>
</NavLink>
}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<DButton Size="DSize.Sm" Shape="DButtonShape.Circle" class="flex-none" OnClick="ResetToOriginalColour" <DButton Size="DSize.Sm" Shape="DButtonShape.Circle" class="flex-none" OnClick="ResetToOriginalColour"
@@ -94,8 +96,8 @@
<div class="md:hidden"> <div class="md:hidden">
<a class="block rounded-full neo-raised neo-2xs" href="settings"> <a class="block rounded-full neo-raised neo-2xs" href="settings">
<img alt="@CascadingState.User.UserName" class="object-cover w-[30px] h-[30px] rounded-full" <img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full"
src="@CascadingState.User.PictureUrl"> src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
</a> </a>
</div> </div>
@@ -103,6 +105,11 @@
@code { @code {
string menuToggle = "hidden"; string menuToggle = "hidden";
bool isPersonaMenuOpen;
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
? $"background-image:linear-gradient(to left, var(--color-base-100) 55%, transparent), url('{header}');"
: null;
// The toggle and the slider work on the variant that is showing; with system theming that is the OS's choice. // The toggle and the slider work on the variant that is showing; with system theming that is the OS's choice.
bool IsDark => CascadingState.Status.IsDarkTheme(); bool IsDark => CascadingState.Status.IsDarkTheme();
@@ -110,10 +117,18 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
IsLoading = false;
if (!CascadingState.Status.IsMobileMedia()) if (!CascadingState.Status.IsMobileMedia())
menuToggle = default; menuToggle = default;
} }
async Task SwitchPersona(string personaId)
{
var result = await CascadingState.SwitchPersona(personaId);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
private void ToggleNavMenu() private void ToggleNavMenu()
{ {
menuToggle = menuToggle is { Length: > 0 } ? default : "hidden"; menuToggle = menuToggle is { Length: > 0 } ? default : "hidden";
+3 -2
View File
@@ -1,8 +1,9 @@
@code { @* Where AuthorizeRouteView sends a reader who is not signed in: the login page, which comes back here afterwards. *@
@code {
[Inject] NavigationManager Navigation { get; set; } [Inject] NavigationManager Navigation { get; set; }
protected override void OnInitialized() protected override void OnInitialized()
{ {
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}"); Navigation.NavigateTo($"login?returnUrl={Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri))}");
} }
} }
+1
View File
@@ -15,3 +15,4 @@
@import "./theme.css"; @import "./theme.css";
@import "./neo.css"; @import "./neo.css";
@import "./base.css"; @import "./base.css";
@import "./feed.css";
+79
View File
@@ -0,0 +1,79 @@
/* The feed: post HTML as PrivaPub's Mastodon API sends it (already sanitized; Mastodon's link markup hides the
scheme in .invisible and shortens long links with .ellipsis), and the globe's point labels, which replace the
look of globe.gl's own tooltip box. */
@layer components {
.post-content p {
margin-block: 0 0.5em;
}
.post-content p:last-child {
margin-bottom: 0;
}
.post-content a {
color: var(--color-primary);
overflow-wrap: anywhere;
}
.post-content a.mention,
.post-content a.hashtag {
text-decoration-line: none;
font-weight: 600;
}
.post-content .invisible {
display: none;
}
.post-content .ellipsis::after {
content: "…";
}
.post-content blockquote {
border-inline-start: 3px solid var(--neo-strong);
padding-inline-start: 0.75rem;
opacity: 0.85;
}
.post-content pre,
.post-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
white-space: pre-wrap;
}
.post-content ul {
list-style: disc;
padding-inline-start: 1.25rem;
}
.post-content ol {
list-style: decimal;
padding-inline-start: 1.25rem;
}
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. */
.tooltip-content:has(> .nerd-stats) {
border-radius: var(--radius-box);
padding: 0.5rem 0.65rem;
max-width: min(22rem, 90vw);
}
.float-tooltip-kap {
padding: 0 !important;
background: transparent !important;
}
.globe-label {
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 0.4rem 0.55rem;
border-radius: 0.5rem;
color: var(--color-base-content);
background: var(--color-base-100);
box-shadow: 0 2px 10px rgb(0 0 0 / 0.25);
}
.globe-label span {
opacity: 0.65;
}
}
+6 -1
View File
@@ -7,7 +7,6 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.Localization @using Microsoft.Extensions.Localization
@@ -23,10 +22,13 @@
@using decePubClient.Shared @using decePubClient.Shared
@using decePubClient.Models @using decePubClient.Models
@using decePubClient.Models.Types @using decePubClient.Models.Types
@using decePubClient.Models.Mastodon
@using decePubClient.Helpers @using decePubClient.Helpers
@using decePubClient.Resources @using decePubClient.Resources
@using decePubClient.Extensions @using decePubClient.Extensions
@using decePubClient.Components @using decePubClient.Components
@using decePubClient.Components.Feed
@using decePubClient.Components.Nerd
@using decePubClient.Components.Daisy @using decePubClient.Components.Daisy
@using decePubClient.Components.Daisy.Actions @using decePubClient.Components.Daisy.Actions
@using decePubClient.Components.Daisy.DataDisplay @using decePubClient.Components.Daisy.DataDisplay
@@ -36,3 +38,6 @@
@using decePubClient.Components.Daisy.Mockup @using decePubClient.Components.Daisy.Mockup
@using decePubClient.Components.Daisy.Navigation @using decePubClient.Components.Daisy.Navigation
@using decePubClient.LayerComponents @using decePubClient.LayerComponents
@using PrivaPub.ClientModels
@using PrivaPub.ClientModels.Group
@using PrivaPub.ClientModels.User.Avatar
+7 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -11,7 +11,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BlazorZXingJs" Version="0.5.13" /> <PackageReference Include="BlazorZXingJs" Version="0.5.13" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="BlazorDownloadFileFast" Version="1.0.0.1" /> <PackageReference Include="BlazorDownloadFileFast" Version="1.0.0.1" />
@@ -64,6 +64,11 @@
</ItemGroup> </ItemGroup>
</Target> </Target>
<ItemGroup>
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Update="Resources\ErrorMessages.Designer.cs"> <Compile Update="Resources\ErrorMessages.Designer.cs">
<DesignTime>True</DesignTime> <DesignTime>True</DesignTime>
+5
View File
@@ -0,0 +1,5 @@
{
"AppConfiguration": {
"ApiBaseAddress": "http://127.0.0.1:6970"
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"AppConfiguration": { "AppConfiguration": {
"Version": "0.1", "Version": "0.1",
"ApiBaseAddress": "https://privapub.thepra.dev" "ApiBaseAddress": "https://privapub.thepra.dev",
"ClientId": "decepub"
} }
} }
-1
View File
@@ -21,7 +21,6 @@
</div> </div>
<script src="_framework/blazor.webassembly.js"></script> <script src="_framework/blazor.webassembly.js"></script>
<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
<script src="rxjs.7.4.0.min.js"></script> <script src="rxjs.7.4.0.min.js"></script>
<script src="_content/DnetIndexedDb/dnet-indexeddb.js"></script> <script src="_content/DnetIndexedDb/dnet-indexeddb.js"></script>
<script src="_content/Toolbelt.Blazor.HeadElement.Services/script.min.js"></script> <script src="_content/Toolbelt.Blazor.HeadElement.Services/script.min.js"></script>
+50
View File
@@ -0,0 +1,50 @@
// The feed page's scrolling (Pages/Index.razor): tells .NET when the end of the list comes near, so the next page loads
// before the reader gets there, and whether the page is visible, so polling for new posts stops in a hidden tab.
let observer = null
let sentinel = null
let reference = null
export function observeEnd(dotNetReference, sentinelId, rootId) {
dispose()
reference = dotNetReference
sentinel = document.getElementById(sentinelId)
const root = document.getElementById(rootId)
if (!sentinel || !root)
return
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting))
nearEnd()
}, { root, rootMargin: "0px 0px 800px 0px" })
observer.observe(sentinel)
}
// After a page is added: if the end is still in sight (a short page), ask for the next one too.
export function checkEnd() {
if (!sentinel || !observer)
return
const root = observer.root
const end = sentinel.getBoundingClientRect().top
const bottom = root.getBoundingClientRect().bottom
if (end < bottom + 800)
nearEnd()
}
export function isVisible() {
return document.visibilityState === "visible"
}
export function scrollToTop(rootId) {
document.getElementById(rootId)?.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" })
}
export function dispose() {
observer?.disconnect()
observer = null
sentinel = null
reference = null
}
function nearEnd() {
reference?.invokeMethodAsync("NearEnd").catch(e => console.error(e))
}
+452
View File
@@ -0,0 +1,452 @@
// The feed's globe (Components/Feed/GlobePanel.razor), on the vendored globe.gl (wwwroot/vendor/globe, loaded the first
// time a globe is shown). It looks at the average place of the located posts in view: the spherical mean of their
// points, so posts on either side of the antimeridian average correctly, from an altitude that fits their spread.
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
// view. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion, and nothing
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
// call per feed change (setPosts), the cards are matched by data-post-id.
const vendor = new URL("../vendor/globe/", import.meta.url).href
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
let loading = null
let state = null
function loadLibrary() {
if (window.Globe)
return Promise.resolve(window.Globe)
loading ??= new Promise((resolve, reject) => {
const script = document.createElement("script")
script.src = vendor + "globe.gl.min.js"
script.onload = () => resolve(window.Globe)
script.onerror = () => { loading = null; reject(new Error("globe.gl could not be loaded")) }
document.head.appendChild(script)
})
return loading
}
// Creates the globe in element once it has a size (it may start hidden on a phone), following the cards of the
// scroll container.
export function create(element, scrollContainerId) {
dispose()
state = {
element,
scroller: document.getElementById(scrollContainerId),
globe: null,
points: new Map(),
self: null,
visible: new Set(),
hovered: null,
view: null,
spreadKm: 0,
frames: 0,
fps: 0,
renderer: null,
texture: null,
paused: false,
observers: [],
listeners: [],
timers: [],
error: null
}
const sizeObserver = new ResizeObserver(() => resize())
sizeObserver.observe(element)
state.observers.push(sizeObserver)
resize()
watchCards()
watchTheme()
watchVisibility()
}
// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range }], and PrivaPub's
// own place ({ lat, lng, label } or null), where every arc starts.
export function setPosts(posts, self) {
if (!state)
return
state.points = new Map(posts.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng)).map(p => [p.key, p]))
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
drawPoints()
scheduleView()
}
// Live numbers for the nerd stats overlay; null until the globe exists.
export function stats() {
if (!state?.globe)
return state?.error ? { error: state.error } : null
const camera = state.globe.pointOfView()
const visibleLocated = [...state.visible].filter(key => state.points.has(key)).length
return {
latitude: round(camera.lat, 2),
longitude: round(camera.lng, 2),
altitude: round(camera.altitude, 2),
visible: state.visible.size,
visibleLocated,
centroidLatitude: state.view ? round(state.view.lat, 2) : null,
centroidLongitude: state.view ? round(state.view.lng, 2) : null,
spreadKm: Math.round(state.spreadKm),
points: aggregate().length,
posts: state.points.size,
servers: new Set([...state.points.values()].map(p => p.host)).size,
fps: state.fps,
renderer: state.renderer,
texture: state.texture,
autoRotate: !!state.globe.controls().autoRotate,
paused: state.paused,
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
arcs: state.globe.arcsData().length,
rings: state.globe.ringsData().length
}
}
export function dispose() {
if (!state)
return
state.observers.forEach(observer => observer.disconnect())
state.listeners.forEach(([target, type, listener]) => target.removeEventListener(type, listener))
state.timers.forEach(timer => clearInterval(timer))
if (state.globe) {
state.globe.pauseAnimation()
state.globe._destructor?.()
state.element.replaceChildren()
}
state = null
}
function resize() {
const { width, height } = state.element.getBoundingClientRect()
if (width < 10 || height < 10) {
pause(true)
return
}
if (!state.globe) {
build(width, height)
return
}
state.globe.width(width).height(height)
pause(document.visibilityState !== "visible")
}
async function build(width, height) {
if (state.building)
return
state.building = true
const current = state
try {
const Globe = await loadLibrary()
if (state !== current)
return
const globe = new Globe(state.element, { rendererConfig: { antialias: true, alpha: true } })
state.globe = globe
globe.width(width).height(height)
.backgroundColor("rgba(0,0,0,0)")
.showAtmosphere(true)
.atmosphereAltitude(0.18)
.pointsMerge(false)
.pointAltitude(p => 0.01 + Math.min(p.count, 30) * 0.004)
.pointRadius(p => 0.25 + Math.min(Math.sqrt(p.count), 6) * 0.12)
.pointColor(p => colour(p.src))
.pointLabel(p => label(p))
.pointsTransitionDuration(reducedMotion.matches ? 0 : 600)
.ringColor(r => t => r.colour.replace(/[\d.]+\)$/, `${(1 - t).toFixed(2)})`))
.ringMaxRadius(r => r.radius)
.ringPropagationSpeed(r => r.speed)
.ringRepeatPeriod(r => r.period)
.arcColor(() => [colour("self", 0.9), colour("server", 0.9)])
.arcStroke(0.6)
.arcAltitudeAutoScale(0.4)
.arcDashLength(0.45)
.arcDashGap(0.25)
.arcDashInitialGap(1)
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
const controls = globe.controls()
controls.autoRotateSpeed = 0.35
controls.enableDamping = true
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.5 * narrowness() }, 0)
applyTheme()
readRenderer()
countFrames()
drawPoints()
scheduleView()
pause(document.visibilityState !== "visible")
} catch (e) {
state.error = e.message
console.error(e)
window.logFromJs?.(e.message, "globe.build")
} finally {
if (state === current)
state.building = false
}
}
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
// the list changes; hover by delegation on the container.
function watchCards() {
const scroller = state.scroller
if (!scroller)
return
const visibility = new IntersectionObserver(entries => {
for (const entry of entries) {
const key = entry.target.dataset.postId
if (entry.isIntersecting)
state.visible.add(key)
else
state.visible.delete(key)
}
scheduleView()
}, { root: scroller, threshold: 0.35 })
const observeAll = () => scroller.querySelectorAll("[data-post-id]").forEach(card => visibility.observe(card))
observeAll()
const changes = new MutationObserver(mutations => {
for (const mutation of mutations)
for (const node of mutation.removedNodes)
if (node.nodeType === 1 && node.dataset?.postId)
state.visible.delete(node.dataset.postId)
observeAll()
scheduleView()
})
changes.observe(scroller, { childList: true, subtree: true })
state.observers.push(visibility, changes)
listen(scroller, "mouseover", event => {
const card = event.target.closest?.("[data-post-id]")
if (card && card.dataset.postId !== state.hovered)
hover(card.dataset.postId)
})
listen(scroller, "mouseleave", () => hover(null))
listen(scroller, "mouseout", event => {
const from = event.target.closest?.("[data-post-id]")
const to = event.relatedTarget?.closest?.("[data-post-id]")
if (from && !to)
hover(null)
})
}
function watchTheme() {
const themeObserver = new MutationObserver(() => applyTheme())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "style", "class"] })
state.observers.push(themeObserver)
const scheme = matchMedia("(prefers-color-scheme: dark)")
listen(scheme, "change", () => applyTheme())
}
function watchVisibility() {
listen(document, "visibilitychange", () => pause(document.visibilityState !== "visible" || !hasSize()))
listen(reducedMotion, "change", () => scheduleView())
}
function listen(target, type, listener) {
target.addEventListener(type, listener)
state.listeners.push([target, type, listener])
}
function hasSize() {
const { width, height } = state.element.getBoundingClientRect()
return width >= 10 && height >= 10
}
function pause(paused) {
if (!state?.globe || state.paused === paused)
return
state.paused = paused
if (paused)
state.globe.pauseAnimation()
else
state.globe.resumeAnimation()
}
function applyTheme() {
if (!state?.globe)
return
const dark = window.neoTheme?.isDark?.() ?? matchMedia("(prefers-color-scheme: dark)").matches
const texture = dark ? "night" : "day"
if (state.texture !== texture) {
state.texture = texture
state.globe.globeImageUrl(textures[texture])
}
state.colours = null
state.globe.atmosphereColor(colour("server", 1))
drawPoints()
}
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read).
function colour(kind, alpha = 0.95) {
state.colours ??= (() => {
const probe = document.createElement("span")
probe.style.display = "none"
document.body.appendChild(probe)
const read = token => {
probe.style.color = `var(${token})`
return getComputedStyle(probe).color
}
const colours = {
server: read("--color-primary"),
place: read("--color-accent"),
country: read("--color-secondary"),
nearyou: read("--color-success"),
self: read("--color-base-content")
}
probe.remove()
return colours
})()
const rgb = (state.colours[kind] ?? state.colours.server).match(/[\d.]+/g)?.slice(0, 3) ?? [128, 128, 128]
return `rgba(${rgb.join(",")},${alpha})`
}
// Posts at the same place are one point; PrivaPub's own server is a point of its own.
function aggregate() {
const byPlace = new Map()
for (const post of state.points.values()) {
const kind = post.src === "event" ? "place" : post.src
const id = `${kind}:${post.lat.toFixed(2)},${post.lng.toFixed(2)}`
const point = byPlace.get(id) ?? { lat: post.lat, lng: post.lng, src: kind, label: post.label, hosts: new Set(), count: 0 }
point.count++
if (post.host)
point.hosts.add(post.host)
byPlace.set(id, point)
}
const points = [...byPlace.values()]
if (state.self)
points.push({ lat: state.self.lat, lng: state.self.lng, src: "self", label: state.self.label, hosts: new Set([state.self.host]), count: 1 })
return points
}
function drawPoints() {
if (!state?.globe)
return
state.globe.pointsData(aggregate())
drawRings()
}
function label(point) {
const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
const hosts = [...point.hosts].slice(0, 4).map(escape).join("<br>")
const more = point.hosts.size > 4 ? `<br>+${point.hosts.size - 4}` : ""
return `<div class="globe-label"><b>${escape(point.label || "")}</b><br>${hosts}${more}` +
`<br><span>${point.count} · ${escape(point.src)} · ${point.lat.toFixed(2)}, ${point.lng.toFixed(2)}</span></div>`
}
// The pulse under the hovered post, and the reach of located posts around the reader.
function drawRings() {
if (!state?.globe)
return
const rings = []
const hovered = state.hovered && state.points.get(state.hovered)
if (hovered)
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
const reader = [...state.points.values()].find(p => p.src === "nearyou")
if (reader && reach > 0)
rings.push({ lat: reader.lat, lng: reader.lng, radius: reach / 111, speed: reach / 111 / 2, period: reducedMotion.matches ? 100000 : 2400, colour: colour("nearyou", 0.8) })
state.globe.ringsData(rings)
}
function hover(key) {
if (!state?.globe || state.hovered === key)
return
state.hovered = key
const post = key && state.points.get(key)
drawRings()
if (!post) {
state.globe.arcsData([])
fly(state.view, 900)
return
}
const from = post.src === "place" || post.src === "event"
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
: state.self
state.globe.arcsData(from && distance(from, post) > 30
? [{ startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng }]
: [])
const camera = state.globe.pointOfView()
if (distance(camera, post) > 2500)
fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900)
}
let viewTimer = null
function scheduleView() {
clearTimeout(viewTimer)
viewTimer = setTimeout(updateView, 250)
}
// The spherical mean of the located posts in view, and an altitude that fits their spread.
function updateView() {
if (!state?.globe)
return
const located = [...state.visible].map(key => state.points.get(key)).filter(Boolean)
const controls = state.globe.controls()
if (located.length === 0) {
state.view = null
state.spreadKm = 0
controls.autoRotate = !reducedMotion.matches
return
}
controls.autoRotate = false
let x = 0, y = 0, z = 0
for (const p of located) {
const lat = p.lat * Math.PI / 180, lng = p.lng * Math.PI / 180
x += Math.cos(lat) * Math.cos(lng)
y += Math.cos(lat) * Math.sin(lng)
z += Math.sin(lat)
}
const mean = {
lat: Math.atan2(z, Math.hypot(x, y)) * 180 / Math.PI,
lng: Math.atan2(y, x) * 180 / Math.PI
}
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
mean.altitude = Math.min(3.2, Math.max(1.1, 1.1 + state.spreadKm / 3500) * narrowness())
state.view = mean
if (!state.hovered)
fly(mean, 1200)
}
function fly(view, ms) {
if (!view || !state?.globe)
return
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
}
// A column taller than wide shows less of the globe across: the camera moves back to keep it whole.
function narrowness() {
const { width, height } = state.element.getBoundingClientRect()
return width > 0 && height > width ? Math.min(1.8, height / width) : 1
}
// Great-circle distance in kilometres.
function distance(a, b) {
const toRadians = degrees => degrees * Math.PI / 180
const dLat = toRadians(b.lat - a.lat), dLng = toRadians(b.lng - a.lng)
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(a.lat)) * Math.cos(toRadians(b.lat)) * Math.sin(dLng / 2) ** 2
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)))
}
function readRenderer() {
try {
const gl = state.globe.renderer().getContext()
const info = gl.getExtension("WEBGL_debug_renderer_info")
state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
} catch {
state.renderer = null
}
}
// Frames per second, counted over each second while the globe renders.
function countFrames() {
const current = state
const tick = () => {
if (state !== current)
return
if (!state.paused)
state.frames++
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
state.timers.push(setInterval(() => {
state.fps = state.paused ? 0 : state.frames
state.frames = 0
}, 1000))
}
function round(value, digits) {
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null
}
+15
View File
@@ -161,4 +161,19 @@ window.shareText = (title, text) => {
console.error(e) console.error(e)
window.logFromJs(e.message, "shareText") window.logFromJs(e.message, "shareText")
} }
}// This device's position for located posts and the Nearby feed, asked only when the reader chooses one of them. It
// goes to PrivaPub only, which keeps located posts on the server and rounds what it stores.
window.feedPosition = () => new Promise(resolve => {
try {
if (!navigator.geolocation)
return resolve({ error: "unsupported" })
navigator.geolocation.getCurrentPosition(
position => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy: position.coords.accuracy }),
error => resolve({ error: error.message || String(error.code) }),
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 600000 })
} catch (e) {
console.error(e)
window.logFromJs(e.message, "feedPosition")
resolve({ error: e.message })
} }
})