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
+5367 -832

No files matched your search

+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";
}
}
+6 -1
View File
@@ -4,4 +4,9 @@ public class AppConfiguration
{
public string Version { 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
{
/// <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
{
/// <summary>The root's JWT, for /clientapi and for exchanging persona tokens.</summary>
public string Token { get; set; }
/// <summary>When the JWT expires, in .NET ticks (JwtUser.Expiration).</summary>
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 User User { get; set; }
//public bool UserHasPolicies(params string[] policies)
//{
// var hasAllPolicies = true;
// foreach (var policy in policies)
// if (!(User?.Policies?.Any(p => p == policy) ?? false))
// {
// hasAllPolicies = false;
// break;
// }
// return hasAllPolicies;
//}
/// <summary>The root's login name; shown only to its owner.</summary>
public string UserName { get; set; }
public List<string> Policies { get; set; } = [];
/// <summary>The avatar id of the persona the client acts as.</summary>
public string PersonaId { get; set; }
/// <summary>Avatar id → that persona's Mastodon access token.</summary>
public Dictionary<string, string> PersonaTokens { get; set; } = [];
}
}
+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
{
Home,
Local,
Federation
Federation,
Nearby,
Groups,
Direct,
Notifications,
Favourites,
Bookmarks,
Tag
}
}