R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream
Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d08fbabaa5
commit
87529bca49
42 files changed
+1505
-77
No files matched your search
@@ -128,7 +128,8 @@ namespace PrivaPub.Domain.Portability
|
||||
var follows = await DB.Default.Find<Following>().Match(f => f.AvatarId == actor.Id).ExecuteAsync(token);
|
||||
var csv = new StringBuilder("Account address,Show boosts,Notify on new posts,Languages\n");
|
||||
foreach (var follow in follows)
|
||||
csv.Append($"{Csv(await handles.Of(follow.TargetActorURI, token))},{(follow.ShowReblogs ? "true" : "false")},false,\n");
|
||||
csv.Append($"{Csv(await handles.Of(follow.TargetActorURI, token))},{(follow.ShowReblogs ? "true" : "false")},{(follow.Notify ? "true" : "false")},"
|
||||
+ $"{Csv(string.Join(", ", follow.Languages ?? new List<string>()))}\n");
|
||||
await Text(zip, "following_accounts.csv", csv.ToString(), token);
|
||||
|
||||
csv.Clear();
|
||||
|
||||
@@ -707,7 +707,10 @@ namespace PrivaPub.Domain.Portability
|
||||
|| await DB.Default.Find<Following>().Match(f => f.AvatarId == run.Me.Id && f.TargetActorURI == uri).ExecuteAnyAsync(token))
|
||||
return false;
|
||||
var boosts = cells.Length < 2 || !cells[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
return await run.Services.GetRequiredService<IFollowService>().FollowAs(run.Me, uri, boosts, token) != default;
|
||||
// Mastodon's columns: notify on new posts, and the languages (", " between them) its posts reach the home in (R8)
|
||||
var notify = cells.Length >= 3 && cells[2].Trim().Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
var languages = cells.Length >= 4 ? cells[3].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : [];
|
||||
return await run.Services.GetRequiredService<IFollowService>().FollowAs(run.Me, uri, boosts, token, notify, languages) != default;
|
||||
}
|
||||
|
||||
// lists by title, with only the accounts the persona follows
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Controllers;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
public interface IFeaturedTags
|
||||
{
|
||||
/// <summary>The persona features a hashtag (already featured, it is as it was): the row, or why not.</summary>
|
||||
Task<(FeaturedTag Tag, string Error)> Feature(LocalActor persona, string name, CancellationToken token);
|
||||
|
||||
/// <summary>The persona stops featuring a hashtag, named by the row's id or by its name: the row taken away, if any.</summary>
|
||||
Task<FeaturedTag> Unfeature(LocalActor persona, string id, string name, CancellationToken token);
|
||||
|
||||
Task<List<FeaturedTag>> Of(string avatarId, CancellationToken token);
|
||||
|
||||
/// <summary>An account elsewhere features a hashtag (Add of a Hashtag) or stops (Remove).</summary>
|
||||
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
|
||||
|
||||
/// <summary>An account elsewhere's featuredTags collection, read again (with its counts, at most daily).</summary>
|
||||
Task Sync(ForeignAvatar owner, CancellationToken token);
|
||||
}
|
||||
|
||||
// Featured hashtags (Mastodon's featured tags, completion program R8). A persona's are public: its featuredTags
|
||||
// collection (/peasants/{name}/tattoos) lists them as Hashtag objects, and featuring one sends Add{Hashtag} to its
|
||||
// followers, unfeaturing it Remove{Hashtag}, both with its `featured` collection (/trophies) as the target: that is how
|
||||
// Mastodon sends them (its AddHashtagSerializer), and the only target Mastodon takes a Hashtag from. Received the same
|
||||
// way, with either collection as the target, and read from an account's featuredTags collection with its counts.
|
||||
public class FeaturedTags : IFeaturedTags
|
||||
{
|
||||
public const int MaxFeatured = 10;//the instance API's max_featured_tags, Mastodon's FeaturedTag::LIMIT
|
||||
public const int MaxRemote = 20;//what we keep of an account elsewhere, whatever its server allows
|
||||
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
|
||||
public FeaturedTags(IDeliveryService delivery, IRemoteActorService remoteActors)
|
||||
{
|
||||
_delivery = delivery;
|
||||
_remoteActors = remoteActors;
|
||||
}
|
||||
|
||||
// a featured hashtag as Mastodon renders it (its HashtagSerializer): the link to this server's page of the tag, which
|
||||
// is what our posts' Hashtag tags link to, and the name as the persona wrote it
|
||||
public static JsonObject Hashtag(string baseAddress, FeaturedTag tag) => new()
|
||||
{
|
||||
["type"] = "Hashtag",
|
||||
["href"] = ActivityPubRenderer.TagUrl(baseAddress, tag.Name),
|
||||
["name"] = "#" + (string.IsNullOrEmpty(tag.DisplayName) ? tag.Name : tag.DisplayName)
|
||||
};
|
||||
|
||||
public async Task<List<FeaturedTag>> Of(string avatarId, CancellationToken token) =>
|
||||
await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == avatarId).Sort(t => t.ID, Order.Ascending).ExecuteAsync(token);
|
||||
|
||||
public async Task<(FeaturedTag Tag, string Error)> Feature(LocalActor persona, string name, CancellationToken token)
|
||||
{
|
||||
var display = name?.Trim().TrimStart('#');
|
||||
var normal = TagsController.Normalise(display);
|
||||
if (!TagsController.IsHashtag(normal))
|
||||
return (default, "Validation failed: Tag is invalid");
|
||||
var existing = await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == persona.Id && t.Name == normal).ExecuteFirstAsync(token);
|
||||
if (existing != default)
|
||||
return (existing, default);
|
||||
if (await DB.Default.CountAsync<FeaturedTag>(t => t.AvatarId == persona.Id, token) >= MaxFeatured)
|
||||
return (default, $"Validation failed: You can feature at most {MaxFeatured} hashtags");
|
||||
var tag = new FeaturedTag
|
||||
{
|
||||
AvatarId = persona.Id,
|
||||
Name = normal,
|
||||
DisplayName = display.Length <= 100 ? display : normal
|
||||
};
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(tag, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return (await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == persona.Id && t.Name == normal).ExecuteFirstAsync(token), default);
|
||||
}
|
||||
await Tell(persona, tag, add: true, token);
|
||||
return (tag, default);
|
||||
}
|
||||
|
||||
public async Task<FeaturedTag> Unfeature(LocalActor persona, string id, string name, CancellationToken token)
|
||||
{
|
||||
var normal = id == default ? TagsController.Normalise(name) : default;
|
||||
var tag = id != default
|
||||
? await DB.Default.Find<FeaturedTag>().Match(t => t.ID == id && t.AvatarId == persona.Id).ExecuteFirstAsync(token)
|
||||
: await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == persona.Id && t.Name == normal).ExecuteFirstAsync(token);
|
||||
if (tag == default)
|
||||
return default;
|
||||
var removed = await DB.Default.DeleteAsync<FeaturedTag>(t => t.ID == tag.ID);
|
||||
if (removed.DeletedCount > 0)
|
||||
await Tell(persona, tag, add: false, token);
|
||||
return tag;
|
||||
}
|
||||
|
||||
// to the persona's followers, as Mastodon distributes it (AccountRawDistributionWorker); a suspended persona's goes
|
||||
// nowhere (DeliveryService.HeldBack)
|
||||
async Task Tell(LocalActor persona, FeaturedTag tag, bool add, CancellationToken token)
|
||||
{
|
||||
if (persona is not { IsFederated: true, Kind: Models.Federation.LocalActorKind.Person })
|
||||
return;
|
||||
await _delivery.EnqueueToFollowers(persona, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = persona.ActivityUri($"{(add ? "feature" : "unfeature")}-tag-{tag.ID}-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = add ? "Add" : "Remove",
|
||||
["actor"] = persona.Uri,
|
||||
["object"] = Hashtag(persona.BaseAddress, tag),
|
||||
["target"] = persona.Featured,
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
||||
["cc"] = new JsonArray(persona.Followers)
|
||||
}, token);
|
||||
}
|
||||
|
||||
// what posts carry the tag: a persona's own public and unlisted posts, or the public and unlisted posts we hold of an
|
||||
// account elsewhere, as Mastodon counts a featured tag (statuses_count, last_status_at)
|
||||
public static async Task<Dictionary<string, (long Count, DateTime? Last)>> Usage(string avatarId, string actorUri, IReadOnlyCollection<string> names,
|
||||
DbEntities dbEntities, CancellationToken token)
|
||||
{
|
||||
var usage = new Dictionary<string, (long, DateTime?)>(StringComparer.Ordinal);
|
||||
foreach (var name in names.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var tag = name;
|
||||
var query = avatarId != default
|
||||
? dbEntities.Posts.Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy)
|
||||
: dbEntities.Posts.Match(p => p.ActorURI == actorUri && p.IsFederatedCopy);
|
||||
query.Match(p => p.Tags.Contains(tag) && p.ReblogOfPostId == null
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)).Match(Privacy.VisibilityPolicy.IsShown);
|
||||
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(1).ExecuteFirstAsync(token);
|
||||
if (latest == default)
|
||||
{
|
||||
usage[name] = (0, default);
|
||||
continue;
|
||||
}
|
||||
var counted = avatarId != default
|
||||
? await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && p.Tags.Contains(tag)
|
||||
&& p.ReblogOfPostId == null && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
& f.Where(Privacy.VisibilityPolicy.IsShown), token)
|
||||
: await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.ActorURI == actorUri && p.IsFederatedCopy && p.Tags.Contains(tag)
|
||||
&& p.ReblogOfPostId == null && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
& f.Where(Privacy.VisibilityPolicy.IsShown), token);
|
||||
usage[name] = (counted, latest.CreationDate);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
|
||||
{
|
||||
var (normal, display) = Named(Value(activity["object"], "name"));
|
||||
if (normal == default)
|
||||
{
|
||||
Arrival.Drop("unparseable");
|
||||
return;
|
||||
}
|
||||
if (!add)
|
||||
{
|
||||
var removed = await DB.Default.DeleteAsync<RemoteFeaturedTag>(t => t.ActorURI == owner.ActorURI && t.Name == normal);
|
||||
if (removed is { IsAcknowledged: true, DeletedCount: > 0 })
|
||||
Arrival.Accept("tag-unfeatured");
|
||||
else
|
||||
Arrival.Drop("unknown-tag");
|
||||
return;
|
||||
}
|
||||
if (await DB.Default.CountAsync<RemoteFeaturedTag>(t => t.ActorURI == owner.ActorURI, token) >= MaxRemote)
|
||||
{
|
||||
Arrival.Drop("too-many-tags");
|
||||
return;
|
||||
}
|
||||
var first = await DB.Default.Find<RemoteFeaturedTag>().Match(t => t.ActorURI == owner.ActorURI).Sort(t => t.Position, Order.Ascending).ExecuteFirstAsync(token);
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new RemoteFeaturedTag
|
||||
{
|
||||
ActorURI = owner.ActorURI,
|
||||
Name = normal,
|
||||
DisplayName = display,
|
||||
Href = Link(Value(activity["object"], "href")),
|
||||
Position = (first?.Position ?? 0) - 1
|
||||
}, token);
|
||||
Arrival.Accept("tag-featured");
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
Arrival.Drop("duplicate");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Sync(ForeignAvatar owner, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner.FeaturedTagsURL) || !Origin.Same(owner.FeaturedTagsURL, owner.ActorURI))
|
||||
return;
|
||||
var items = await Items(owner, token);
|
||||
if (items == default)
|
||||
return;
|
||||
var tags = new List<RemoteFeaturedTag>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (Value(item, "type") != "Hashtag" || Named(Value(item, "name")) is not ({ } normal, var display) || tags.Any(t => t.Name == normal))
|
||||
continue;
|
||||
tags.Add(new RemoteFeaturedTag { ActorURI = owner.ActorURI, Name = normal, DisplayName = display, Href = Link(Value(item, "href")), Position = tags.Count });
|
||||
if (tags.Count >= MaxRemote)
|
||||
break;
|
||||
}
|
||||
await DB.Default.DeleteAsync<RemoteFeaturedTag>(t => t.ActorURI == owner.ActorURI);
|
||||
if (tags.Count > 0)
|
||||
await DB.Default.SaveAsync(tags, token);
|
||||
}
|
||||
|
||||
static (string Normal, string Display) Named(string name)
|
||||
{
|
||||
var display = name?.Trim().TrimStart('#');
|
||||
var normal = TagsController.Normalise(display);
|
||||
return TagsController.IsHashtag(normal) ? (normal, display.Length <= 100 ? display : normal) : (default, default);
|
||||
}
|
||||
|
||||
static string Link(string href) => Uri.TryCreate(href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http" ? href : default;
|
||||
|
||||
// the collection's items, from it or its first page (its own server only), and the pages after, as Mastodon reads them
|
||||
async Task<List<JsonNode>> Items(ForeignAvatar owner, CancellationToken token)
|
||||
{
|
||||
using var scope = HttpScope.For("collection");
|
||||
var collection = await Fetch(owner.FeaturedTagsURL, token);
|
||||
if (collection == default)
|
||||
return default;
|
||||
var items = new List<JsonNode>();
|
||||
var page = collection["first"] is JsonObject embedded ? embedded
|
||||
: Id(collection["first"]) is { } first && Origin.Same(first, owner.ActorURI) ? await Fetch(first, token)
|
||||
: collection;
|
||||
for (var pages = 0; page != default && pages < 3 && items.Count < MaxRemote; pages++)
|
||||
{
|
||||
if ((page["orderedItems"] ?? page["items"]) is JsonArray list)
|
||||
items.AddRange(list.Where(i => i is JsonObject));
|
||||
page = Id(page["next"]) is { } next && Origin.Same(next, owner.ActorURI) ? await Fetch(next, token) : default;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async Task<JsonObject> Fetch(string uri, CancellationToken token)
|
||||
{
|
||||
using var fetched = await _remoteActors.FetchObject(uri, token);
|
||||
return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ namespace PrivaPub.Domain.Social
|
||||
Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
|
||||
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
|
||||
Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token);
|
||||
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
|
||||
/// <summary>Follows an account, or changes how a follow already made shows it: notify and languages (R8) change only
|
||||
/// when given (languages empty: every language).</summary>
|
||||
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token, bool? notify = default,
|
||||
IReadOnlyList<string> languages = default);
|
||||
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
|
||||
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
|
||||
Task<int> ResendPending(DateTime now, CancellationToken token);
|
||||
@@ -93,7 +96,18 @@ namespace PrivaPub.Domain.Social
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
|
||||
// a follow's languages as kept: their primary subtags, lower case; none, every language (null)
|
||||
public static List<string> Languages(IReadOnlyList<string> languages)
|
||||
{
|
||||
var kept = (languages ?? Array.Empty<string>())
|
||||
.Select(l => l?.Trim().Replace('_', '-').Split('-')[0].ToLowerInvariant())
|
||||
.Where(l => l is { Length: >= 2 and <= 3 } && l.All(char.IsAsciiLetterLower))
|
||||
.Distinct().Take(20).ToList();
|
||||
return kept.Count == 0 ? null : kept;
|
||||
}
|
||||
|
||||
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token, bool? notify = default,
|
||||
IReadOnlyList<string> languages = default)
|
||||
{
|
||||
var (local, remote) = await ResolveTarget(target, token);
|
||||
if (local == default && remote == default)
|
||||
@@ -108,6 +122,17 @@ namespace PrivaPub.Domain.Social
|
||||
if (existing.ShowReblogs != showReblogs)
|
||||
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
|
||||
existing.ShowReblogs = showReblogs;
|
||||
if (notify is { } notifying && existing.Notify != notifying)
|
||||
{
|
||||
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.Notify, notifying).ExecuteAsync(token);
|
||||
existing.Notify = notifying;
|
||||
}
|
||||
if (languages != default)
|
||||
{
|
||||
var kept = Languages(languages);
|
||||
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.Languages, kept).ExecuteAsync(token);
|
||||
existing.Languages = kept;
|
||||
}
|
||||
// a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and
|
||||
// drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy
|
||||
if (existing.State == FollowState.Requested && remote != default)
|
||||
@@ -125,6 +150,8 @@ namespace PrivaPub.Domain.Social
|
||||
TargetIsLocal = local != default,
|
||||
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
|
||||
ShowReblogs = showReblogs,
|
||||
Notify = notify == true,
|
||||
Languages = Languages(languages),
|
||||
State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested
|
||||
};
|
||||
following.ID = (string)following.GenerateNewID();
|
||||
@@ -157,6 +184,8 @@ namespace PrivaPub.Domain.Social
|
||||
|
||||
await DB.Default.DeleteAsync<Following>(following.ID);
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
||||
// an endorsement holds only while the follow does, as on Mastodon (R8)
|
||||
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == follower.Id && e.TargetActorURI == targetUri);
|
||||
if (following.TargetIsLocal)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
// Follow suggestions (completion program R8, Mastodon's /api/v1 and /api/v2/suggestions), best first:
|
||||
// - staff: the accounts the administrators pick (ServerSettings.StaffPicks), Mastodon's `featured`;
|
||||
// - past interactions: accounts the persona favourited, boosted or answered and does not follow;
|
||||
// - global: accounts followed by at least GlobalMinimumFollowers personas here (`most_followed`), and personas here whose
|
||||
// public posts of the last 30 days were favourited and boosted the most (`most_interactions`); discoverable ones only.
|
||||
// Never the persona itself, never a sibling persona of its login (suggesting one would pair them the moment the persona
|
||||
// followed it; the server knows the pair, the persona's suggestions must not use it), never an account it follows, asked
|
||||
// to follow, blocked, muted, was blocked by or dismissed, and never an account held back (suspended, a banned login's) or
|
||||
// silenced (the API mapper shows it `limited`). Nothing here is shown to anyone but the persona asking.
|
||||
public static class Suggestions
|
||||
{
|
||||
public const string Staff = "staff";
|
||||
public const string PastInteractions = "past_interactions";
|
||||
public const string Global = "global";
|
||||
public const int GlobalMinimumFollowers = 3;
|
||||
|
||||
const int Recent = 200;
|
||||
const int GlobalSize = 100;
|
||||
static readonly TimeSpan GlobalLifetime = TimeSpan.FromMinutes(10);
|
||||
static (DateTime At, List<(string Id, string Why)> Ids) _global = (DateTime.MinValue, new());
|
||||
|
||||
public sealed record Candidate(string AccountId, string Source, List<string> Sources);
|
||||
|
||||
// every candidate for the persona, in order, each once (with every reason it has), none of those it must never see
|
||||
public static async Task<List<Candidate>> For(LocalActor me, IReadOnlyList<string> staffPicks, CancellationToken token)
|
||||
{
|
||||
var excluded = await Excluded(me, token);
|
||||
var found = new List<Candidate>();
|
||||
var byId = new Dictionary<string, Candidate>(StringComparer.Ordinal);
|
||||
void Add(string id, string source, string why)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || excluded.Contains(id))
|
||||
return;
|
||||
if (byId.TryGetValue(id, out var known))
|
||||
{
|
||||
if (!known.Sources.Contains(why))
|
||||
known.Sources.Add(why);
|
||||
return;
|
||||
}
|
||||
var candidate = new Candidate(id, source, new List<string> { why });
|
||||
byId[id] = candidate;
|
||||
found.Add(candidate);
|
||||
}
|
||||
foreach (var id in staffPicks ?? Array.Empty<string>())
|
||||
Add(id, Staff, "featured");
|
||||
foreach (var id in await Interacted(me.Id, token))
|
||||
Add(id, PastInteractions, PastInteractions);//(Mastodon's sources has no word for it since 4.3; its source does)
|
||||
foreach (var (id, why) in await GlobalCandidates(token))
|
||||
Add(id, Global, why);
|
||||
return found;
|
||||
}
|
||||
|
||||
static async Task<HashSet<string>> Excluded(LocalActor me, CancellationToken token)
|
||||
{
|
||||
var excluded = new HashSet<string>(StringComparer.Ordinal) { me.Id };
|
||||
var roots = (await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == me.Id).ExecuteAsync(token)).Select(r => r.RootId).ToList();
|
||||
if (roots.Count > 0)
|
||||
excluded.UnionWith((await DB.Default.Find<RootToAvatar>().Match(r => roots.Contains(r.RootId)).ExecuteAsync(token)).Select(r => r.AvatarId));
|
||||
excluded.UnionWith((await DB.Default.Find<Following>().Match(f => f.AvatarId == me.Id).Project(f => f.Include(x => x.TargetAccountId))
|
||||
.ExecuteAsync(token)).Select(f => f.TargetAccountId));
|
||||
excluded.UnionWith((await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id).ExecuteAsync(token)).Select(b => b.TargetAccountId));
|
||||
// (a persona here that blocked this one)
|
||||
excluded.UnionWith((await DB.Default.Find<Block>().Match(b => b.TargetActorURI == me.Uri).ExecuteAsync(token)).Select(b => b.AvatarId));
|
||||
excluded.UnionWith((await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == me.Id).ExecuteAsync(token)).Select(b => b.AccountId));
|
||||
excluded.UnionWith((await DB.Default.Find<Mute>().Match(m => m.AvatarId == me.Id).ExecuteAsync(token)).Select(m => m.TargetAccountId));
|
||||
excluded.UnionWith((await DB.Default.Find<SuggestionDismissal>().Match(d => d.AvatarId == me.Id).ExecuteAsync(token)).Select(d => d.AccountId));
|
||||
excluded.UnionWith(await Counted.HeldBackPersonas(token));
|
||||
excluded.Remove(default);
|
||||
return excluded;
|
||||
}
|
||||
|
||||
// the accounts whose posts the persona favourited, boosted or answered lately, the most often first
|
||||
static async Task<List<string>> Interacted(string avatarId, CancellationToken token)
|
||||
{
|
||||
var authors = new List<(string Id, int Age)>();
|
||||
var liked = (await DB.Default.Find<Favourite>().Match(f => f.AccountId == avatarId).Sort(f => f.ID, Order.Descending).Limit(Recent)
|
||||
.ExecuteAsync(token)).Select(f => f.PostId).ToList();
|
||||
var boosted = (await DB.Default.Find<PostEntity>().Match(p => p.AuthorAccountId == avatarId && p.ReblogOfPostId != null && !p.DeletedAt.HasValue)
|
||||
.Sort(p => p.ID, Order.Descending).Limit(Recent).Project(p => p.Include(x => x.ReblogOfPostId)).ExecuteAsync(token)).Select(p => p.ReblogOfPostId).ToList();
|
||||
var interacted = liked.Concat(boosted).Distinct().ToList();
|
||||
var posts = interacted.Count == 0 ? new Dictionary<string, PostEntity>()
|
||||
: (await DB.Default.Find<PostEntity>().Match(p => interacted.Contains(p.ID))
|
||||
.Project(p => p.Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.ReblogOfPostId)).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ID);
|
||||
authors.AddRange(interacted.Select((id, age) => (posts.GetValueOrDefault(id) is { ReblogOfPostId: null } post ? post.AuthorAccountId ?? post.GroupUserId : default, age)));
|
||||
var answered = await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && p.InReplyToAccountId != null && !p.DeletedAt.HasValue)
|
||||
.Sort(p => p.ID, Order.Descending).Limit(Recent).Project(p => p.Include(x => x.InReplyToAccountId)).ExecuteAsync(token);
|
||||
authors.AddRange(answered.Select((p, age) => (p.InReplyToAccountId, age)));
|
||||
return authors.Where(a => !string.IsNullOrEmpty(a.Id) && a.Id != avatarId)
|
||||
.GroupBy(a => a.Id).OrderByDescending(g => g.Count()).ThenBy(g => g.Min(a => a.Age)).Select(g => g.Key).ToList();
|
||||
}
|
||||
|
||||
// the same for every persona, worked out at most every ten minutes
|
||||
static async Task<List<(string Id, string Why)>> GlobalCandidates(CancellationToken token)
|
||||
{
|
||||
var cached = _global;
|
||||
if (DateTime.UtcNow - cached.At < GlobalLifetime)
|
||||
return cached.Ids;
|
||||
var ids = new List<(string, string)>();
|
||||
|
||||
var followed = await DB.Default.Fluent<Following>()
|
||||
.Match(f => f.State == FollowState.Accepted && f.FollowerKind == null)
|
||||
.Group(f => f.TargetAccountId, g => new { Id = g.Key, Count = g.Count() })
|
||||
.Match(g => g.Count >= GlobalMinimumFollowers)
|
||||
.SortByDescending(g => g.Count)
|
||||
.Limit(GlobalSize)
|
||||
.ToListAsync(token);
|
||||
var followedIds = followed.Select(f => f.Id).ToList();
|
||||
var discoverable = (await DB.Default.Find<Avatar>().Match(a => followedIds.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue
|
||||
&& !a.SilencedAt.HasValue && !a.SuspendedAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID)
|
||||
.Concat((await DB.Default.Find<ForeignAvatar>().Match(f => followedIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue
|
||||
&& !f.SilencedAt.HasValue && !f.SuspendedAt.HasValue
|
||||
&& (f.AvatarType == AvatarType.Person || f.AvatarType == AvatarType.Service || f.AvatarType == AvatarType.Organization))
|
||||
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
ids.AddRange(followed.Where(f => discoverable.Contains(f.Id)).Select(f => (f.Id, "most_followed")));
|
||||
|
||||
var since = DateTime.UtcNow.AddDays(-30);
|
||||
var interactions = await DB.Default.Fluent<PostEntity>()
|
||||
.Match(p => !p.IsFederatedCopy && p.GroupUserId != null && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null
|
||||
&& !p.DeletedAt.HasValue && !p.AuthorLimited && !p.AuthorSuspended && p.CreationDate >= since)
|
||||
.Group(p => p.GroupUserId, g => new { Id = g.Key, Count = g.Sum(p => p.FavouritesCount + p.ReblogsCount) })
|
||||
.Match(g => g.Count > 0)
|
||||
.SortByDescending(g => g.Count)
|
||||
.Limit(GlobalSize)
|
||||
.ToListAsync(token);
|
||||
var active = interactions.Select(i => i.Id).ToList();
|
||||
var shown = (await DB.Default.Find<Avatar>().Match(a => active.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue
|
||||
&& !a.SilencedAt.HasValue && !a.SuspendedAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
ids.AddRange(interactions.Where(i => shown.Contains(i.Id)).Select(i => (i.Id, "most_interactions")));
|
||||
|
||||
_global = (DateTime.UtcNow, ids);
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (parent != default && Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
if (post.Visibility == PostVisibility.Direct)
|
||||
await Fanout.Conversation(post.ConversationId, token);//the `direct` streams (R8), now that it is saved
|
||||
await _polls.Scheduled(post, token);
|
||||
if (post.QuoteState == QuoteState.Accepted)
|
||||
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
||||
|
||||
@@ -35,6 +35,8 @@ namespace PrivaPub.Domain.Timelines
|
||||
var recipients = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (!post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId))
|
||||
recipients.Add(post.GroupUserId);
|
||||
// those following the author with notify (R8), told of the post once it reached their home
|
||||
var notifying = new List<string>();
|
||||
|
||||
switch (post.Visibility)
|
||||
{
|
||||
@@ -44,7 +46,11 @@ namespace PrivaPub.Domain.Timelines
|
||||
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
|
||||
foreach (var following in followers)
|
||||
if (await Shows(following, post, token))
|
||||
{
|
||||
recipients.Add(following.AvatarId);
|
||||
if (following.Notify && Notifies(post))
|
||||
notifying.Add(following.AvatarId);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(post.AudienceURI))
|
||||
// (the server's reader of feeds follows communities for nobody's home)
|
||||
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token))
|
||||
@@ -100,6 +106,8 @@ namespace PrivaPub.Domain.Timelines
|
||||
if (string.IsNullOrEmpty(post.ReblogOfPostId))
|
||||
foreach (var mention in post.Mentions.Where(m => m.IsLocal && !string.IsNullOrEmpty(m.AccountId)))
|
||||
await Notifications.Add(mention.AccountId, NotificationType.Mention, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
|
||||
foreach (var avatarId in notifying.Where(recipients.Contains).Distinct())
|
||||
await Notifications.Add(avatarId, NotificationType.Status, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
|
||||
if (Streams.Anyone)
|
||||
await Stream(post, recipients, token);
|
||||
}
|
||||
@@ -186,10 +194,36 @@ namespace PrivaPub.Domain.Timelines
|
||||
}
|
||||
}
|
||||
|
||||
// Mastodon's notify: the account's own posts and its replies to itself, never its boosts (FeedInsertWorker)
|
||||
static bool Notifies(Post post) =>
|
||||
string.IsNullOrEmpty(post.ReblogOfPostId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly
|
||||
&& (string.IsNullOrEmpty(post.InReplyToURI) || post.InReplyToAccountId == (post.AuthorAccountId ?? post.GroupUserId));
|
||||
|
||||
// a follow's languages (R8): a post in another language stays out of that home, as on Mastodon; one whose language
|
||||
// is not known comes in
|
||||
static bool InLanguages(Following following, Post post) =>
|
||||
following.Languages is not { Count: > 0 } || string.IsNullOrEmpty(post.Language)
|
||||
|| following.Languages.Contains(post.Language.Replace('_', '-').Split('-')[0].ToLowerInvariant());
|
||||
|
||||
// a direct conversation that has a new post, to the `direct` streams of the personas taking part in it (R8), as
|
||||
// Mastodon's `conversation` event: each connection maps it for its own persona when it sends it
|
||||
public static async Task Conversation(string conversationId, CancellationToken token)
|
||||
{
|
||||
if (!Streams.Anyone || string.IsNullOrEmpty(conversationId))
|
||||
return;
|
||||
var conversation = await DB.Default.Find<DmGroup>().MatchID(conversationId).ExecuteFirstAsync(token);
|
||||
var told = new Streams.Event("conversation", conversationId);
|
||||
foreach (var member in conversation?.Members.Where(m => !m.IsForeign).Select(m => m.AvatarId).Distinct() ?? Enumerable.Empty<string>())
|
||||
if (Streams.Listening(member))
|
||||
Streams.ToPersona(member, new Streams.Key(Streams.Direct), told);
|
||||
}
|
||||
|
||||
async Task<bool> Shows(Following following, Post post, CancellationToken token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(post.ReblogOfPostId))
|
||||
return following.ShowReblogs;
|
||||
if (!InLanguages(following, post))
|
||||
return false;
|
||||
var repliedTo = post.InReplyToAccountId;
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
if (string.IsNullOrEmpty(post.InReplyToURI) || repliedTo == author || repliedTo == following.AvatarId)
|
||||
|
||||
Reference in new issue
Block a user