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:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

+264
View File
@@ -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;
}
}
}
+31 -2
View File
@@ -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);
+149
View File
@@ -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;
}
}
}