diff --git a/Pleroma/ASObject.cs b/Pleroma/ASObject.cs new file mode 100644 index 0000000..26aefc1 --- /dev/null +++ b/Pleroma/ASObject.cs @@ -0,0 +1,21 @@ +namespace Uwaa.Pleroma; + +/// +/// Base class for ActivityStreams objects. +/// +public class ASObject +{ + /// + /// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings + /// + [JsonPropertyName("id")] + public string ID { get; set; } = null!; + + public override string ToString() => ID; + + public override bool Equals(object? obj) => Equals(obj as ASObject); + public bool Equals(ASObject? other) => other is not null && ID == other.ID; + public static bool operator ==(ASObject? left, ASObject? right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ASObject? left, ASObject? right) => !(left == right); + public override int GetHashCode() => ID.GetHashCode(); +} diff --git a/Pleroma/Models/Account.cs b/Pleroma/Models/Account.cs index dd23e3b..f294904 100644 --- a/Pleroma/Models/Account.cs +++ b/Pleroma/Models/Account.cs @@ -1,13 +1,7 @@ namespace Uwaa.Pleroma; -public class Account +public class Account : ASObject { - /// - /// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings - /// - [JsonPropertyName("id")] - public string ID { get; set; } = null!; - [JsonPropertyName("bot")] public bool Bot { get; set; } @@ -74,6 +68,17 @@ public readonly struct AccountID(string id) public readonly string ID = id; public override string ToString() => ID; + + + public static bool operator ==(AccountID left, AccountID right) => left.Equals(right); + + public static bool operator !=(AccountID left, AccountID right) => !(left == right); + + public override bool Equals(object? obj) => (obj is AccountID id && Equals(id)) || (obj is Account status && Equals(status)); + + public bool Equals(AccountID other) => ID == other.ID; + + public override int GetHashCode() => ID.GetHashCode(); } class AccountIDConverter : JsonConverter diff --git a/Pleroma/Models/Status.cs b/Pleroma/Models/Status.cs index e702655..679546e 100644 --- a/Pleroma/Models/Status.cs +++ b/Pleroma/Models/Status.cs @@ -1,13 +1,7 @@ namespace Uwaa.Pleroma; -public class Status +public class Status : ASObject { - /// - /// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings - /// - [JsonPropertyName("id")] - public string ID { get; set; } = null!; - /// /// HTML-encoded status content /// @@ -149,10 +143,10 @@ public class Status /// /// Returns true if a status mentions or replies to a user. /// - /// The ID of the user. - public bool IsInteracting(string id) + /// The ID of the user. + public bool IsInteracting(AccountID user) { - return ReplyToAccount == id || Mentions.Any(m => m.ID == id); + return ReplyToAccount == user.ID || Mentions.Any(m => m.ID == user.ID); } public override string ToString() => $"{Account?.Username ?? "unknown"}: \"{Content}\""; @@ -164,20 +158,23 @@ public class Status public class PleromaStatusData { [JsonPropertyName("content")] - public Content Content { get; set; } = null!; + public PleromaStatusContent Content { get; set; } = null!; [JsonPropertyName("local")] public bool Local { get; set; } + + [JsonPropertyName("quote_id")] + public string? QuoteID { get; set; } } -public class Content +public class PleromaStatusContent { [JsonPropertyName("text/plain")] public string Plain { get; set; } = null!; } [JsonConverter(typeof(StatusIDConverter))] -public readonly struct StatusID(string id) +public readonly struct StatusID(string id) : IEquatable { public static implicit operator StatusID(string id) => new StatusID(id); @@ -186,6 +183,17 @@ public readonly struct StatusID(string id) public readonly string ID = id; public override string ToString() => ID; + + + public static bool operator ==(StatusID left, StatusID right) => left.Equals(right); + + public static bool operator !=(StatusID left, StatusID right) => !(left == right); + + public override bool Equals(object? obj) => (obj is StatusID id && Equals(id)) || (obj is Status status && Equals(status)); + + public bool Equals(StatusID other) => ID == other.ID; + + public override int GetHashCode() => ID.GetHashCode(); } class StatusIDConverter : JsonConverter diff --git a/Pleroma/Pleroma.cs b/Pleroma/Pleroma.cs index f18ddae..daafea4 100644 --- a/Pleroma/Pleroma.cs +++ b/Pleroma/Pleroma.cs @@ -51,11 +51,11 @@ public class Pleroma HttpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorization); } - async Task Retry(HttpRequestMessage req) + async Task Retry(Func reqFactory) { while (true) { - HttpResponseMessage res = await HttpClient.SendAsync(req); + HttpResponseMessage res = await HttpClient.SendAsync(reqFactory()); if (res.StatusCode == HttpStatusCode.NotFound) return default; @@ -110,33 +110,59 @@ public class Pleroma /// /// Fetches the account of the pleroma client. /// - public Task GetAccount() => Retry(new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!; + public Task GetAccount() => Retry(() => new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!; /// /// Fetches an account by ID. /// /// Account ID to fetch - public Task GetAccountByID(string id) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(id)}")); + public Task GetAccountByID(string id) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(id)}")); /// /// Fetches an account by nickname. /// /// User nickname - public Task GetAccountByName(string acct) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/lookup?acct={WebUtility.UrlEncode(acct)}")); + public Task GetAccountByName(string acct) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/lookup?acct={WebUtility.UrlEncode(acct)}")); /// /// Follows the given account. /// /// Account to follow /// The new relationship, if the provided account exists. - public Task Follow(AccountID account) => Retry(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/follow")); + public Task Follow(AccountID account) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/follow")); /// /// Unfollows the given account. /// /// Account to unfollow /// The new state of the relationship, if the provided account exists. - public Task Unfollow(AccountID account) => Retry(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/unfollow")); + public Task Unfollow(AccountID account) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/unfollow")); + + /// + /// Gets accounts which the given account is following, if network is not hidden by the account owner. + /// + /// Account to get the follows of. + /// Return items older than this ID + /// Return the oldest items newer than this ID + /// Return the newest items newer than this ID + /// Return items past this number of items + /// Maximum number of items to return. Will be ignored if it's more than 40 + public Task GetFollowing(AccountID account, + string? max_id = null, + string? min_id = null, + string? since_id = null, + int offset = 0, + int limit = 20) + { + return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{account.ID}/following" + CreateQuery(addPair => + { + if (max_id != null) addPair("max_id", max_id); + if (min_id != null) addPair("min_id", min_id); + if (since_id != null) addPair("since_id", since_id); + if (offset > 0) addPair("offset", offset.ToString()); + if (limit != 20) addPair("limit", limit.ToString()); + })))!; + } /// /// Sets a private note for the given account. @@ -159,12 +185,14 @@ public class Pleroma writer.Flush(); } - mem.Position = 0; - HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/note"); - req.Content = new StreamContent(mem); - req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - - return Retry(req)!; + return Retry(() => + { + mem.Position = 0; + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/note"); + req.Content = new StreamContent(mem); + req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return req; + })!; } /// @@ -174,7 +202,7 @@ public class Pleroma /// A relationship object for the requested account, if the account exists. public async Task GetRelationship(AccountID account) { - Relationship[]? rels = await Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/relationships?id={WebUtility.UrlEncode(account.ID)}")); + Relationship[]? rels = await Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/relationships?id={WebUtility.UrlEncode(account.ID)}")); if (rels == null || rels.Length == 0) return null; else @@ -270,12 +298,14 @@ public class Pleroma writer.Flush(); } - mem.Position = 0; - HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses"); - req.Content = new StreamContent(mem); - req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - - return Retry(req)!; + return Retry(() => + { + mem.Position = 0; + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses"); + req.Content = new StreamContent(mem); + req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return req; + })!; } static string CommonMIMEString(CommonMIMEs mime) => mime switch @@ -327,20 +357,22 @@ public class Pleroma async Task Upload(string mime, HttpContent content) { - content.Headers.ContentType = new MediaTypeHeaderValue(mime); - content.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") + return (await Retry(() => { - Name = "\"file\"", - FileName = $"\"{Random.Shared.NextInt64()}\"", - }; + content.Headers.ContentType = new MediaTypeHeaderValue(mime); + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") + { + Name = "\"file\"", + FileName = $"\"{Random.Shared.NextInt64()}\"", + }; - MultipartFormDataContent form = [content]; - HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/media") - { - Content = form - }; - - return (await Retry(req))!; + MultipartFormDataContent form = [content]; + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/media") + { + Content = form + }; + return req; + }))!; } /// @@ -366,12 +398,14 @@ public class Pleroma writer.Flush(); } - mem.Position = 0; - HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/reblog"); - req.Content = new StreamContent(mem); - req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - - return Retry(req); + return Retry(() => + { + mem.Position = 0; + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/reblog"); + req.Content = new StreamContent(mem); + req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return req; + }); } /// @@ -379,7 +413,7 @@ public class Pleroma /// /// The reblogged status to undo. /// The status, if it exists. - public Task Unreblog(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unreblog"))!; + public Task Unreblog(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unreblog"))!; /// @@ -387,26 +421,39 @@ public class Pleroma /// /// The status to favourite. /// The favourited status, if it exists. - public Task Favourite(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/favourite"))!; + public Task Favourite(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/favourite"))!; /// /// Unfavourites a favorited status, removing it from the account's favourite list. /// /// The status to unfavourite. /// The status, if it exists. - public Task Unfavourite(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unfavourite"))!; + public Task Unfavourite(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unfavourite"))!; /// /// Fetches the latest statuses from a standard timeline. /// + /// The timeline to fetch. + /// Show only local statuses? + /// Show only remote statuses? + /// Show only statuses from the given domain + /// Show only statuses with media attached? + /// Include activities by muted users + /// Exclude the statuses with the given visibilities + /// Filter replies. + /// Return items older than this ID + /// Return the oldest items newer than this ID + /// Return the newest items newer than this ID + /// Return items past this number of items + /// Maximum number of items to return. Will be ignored if it's more than 40 public Task GetTimeline(Timeline timeline, bool local = false, + bool remote = false, string? instance = null, bool only_media = false, - bool remote = false, bool with_muted = false, StatusVisibility[]? exclude_visibilities = null, - ReplyVisibility? reply_visibility = null, + ReplyVisibility reply_visibility = ReplyVisibility.All, string? max_id = null, string? min_id = null, string? since_id = null, @@ -437,7 +484,7 @@ public class Pleroma throw new ArgumentException("Invalid timeline", nameof(timeline)); } - return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/timelines/{timelineName}" + CreateQuery(addPair => + return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/timelines/{timelineName}" + CreateQuery(addPair => { if (local) addPair("local", "true"); if (instance != null) addPair("instance", instance); @@ -445,7 +492,7 @@ public class Pleroma if (remote) addPair("remote", "true"); if (with_muted) addPair("with_muted", "true"); if (exclude_visibilities != null) addPair("exclude_visibilities", JsonSerializer.Serialize(exclude_visibilities)); - if (reply_visibility.HasValue) addPair("reply_visibility", reply_visibility.Value.ToString().ToLowerInvariant()); + if (reply_visibility != ReplyVisibility.All) addPair("reply_visibility", reply_visibility.ToString().ToLowerInvariant()); if (max_id != null) addPair("max_id", max_id); if (min_id != null) addPair("min_id", min_id); if (since_id != null) addPair("since_id", since_id); @@ -458,6 +505,19 @@ public class Pleroma /// /// Fetches the latest statuses from a user's timeline. /// + /// The user to fetch the timeline of. + /// Include only pinned statuses + /// With tag + /// Show only statuses with media attached? + /// Include activities by muted users + /// Exclude reblogs + /// Exclude replies + /// Exclude the statuses with the given visibilities + /// Return items older than this ID + /// Return the oldest items newer than this ID + /// Return the newest items newer than this ID + /// Return items past this number of items + /// Maximum number of items to return. Will be ignored if it's more than 40 public Task GetTimeline(AccountID account, bool pinned = false, string? tagged = null, @@ -473,7 +533,7 @@ public class Pleroma int limit = 20) { - return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/statuses" + CreateQuery(addPair => + return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/statuses" + CreateQuery(addPair => { if (pinned) addPair("pinned", "true"); if (tagged != null) addPair("tagged", tagged); @@ -493,17 +553,17 @@ public class Pleroma /// /// Fetches the context of a status. /// - public Task GetContext(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context")); + public Task GetContext(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context")); /// /// Fetches a status by ID. /// - public Task GetStatus(string id) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(id)}")); + public Task GetStatus(string id) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(id)}")); /// /// Deletes a status. /// - public Task Delete(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}")); + public Task Delete(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}")); /// /// Searches for an accounts, hashtags, and/or statuses. @@ -518,7 +578,6 @@ public class Pleroma /// Return the newest items newer than this ID /// Return items past this number of items /// Maximum number of items to return. Will be ignored if it's more than 40 - /// public Task Search(string query, string? account_id = null, SearchType type = SearchType.All, @@ -530,7 +589,7 @@ public class Pleroma int offset = 0, int limit = 20) { - HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Get, "/api/v2/search" + CreateQuery(addPair => + return Retry(() => new HttpRequestMessage(HttpMethod.Get, "/api/v2/search" + CreateQuery(addPair => { addPair("q", query); if (account_id != null) addPair("account_id", account_id); @@ -542,7 +601,6 @@ public class Pleroma if (since_id != null) addPair("since_id", since_id); if (offset > 0) addPair("offset", offset.ToString()); if (limit != 20) addPair("limit", limit.ToString()); - })); - return Retry(req)!; + })))!; } } diff --git a/Pleroma/ReplyVisibility.cs b/Pleroma/ReplyVisibility.cs index 2cbfc01..13b3c18 100644 --- a/Pleroma/ReplyVisibility.cs +++ b/Pleroma/ReplyVisibility.cs @@ -3,6 +3,18 @@ [JsonConverter(typeof(EnumLowerCaseConverter))] public enum ReplyVisibility { + /// + /// Shows all replies. + /// + All, + + /// + /// Replies directed to you or users you follow. + /// Following, + + /// + /// Replies directed to you. + /// Self, } \ No newline at end of file