using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.User; using System.Linq.Expressions; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Privacy { // What the numbers count, in one place, so a persona's statuses_count, its outbox's totalItems, NodeInfo and the // instance API agree (they used to count four different things). public static class Counted { // Mastodon's statuses_count: every post that is neither deleted nor a direct message, reblogs included. Circle and // located posts count too (owner decision 2026-10-04: "a good balance for the fediverse to work"). public static readonly Expression> Post = p => !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct; // a persona's own posts, or every post in a group, its remote members' included public static Expression> By(LocalActor actor) { var id = actor.Id; return actor.Kind == LocalActorKind.Group ? p => p.GroupId == id && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct : p => p.GroupUserId == id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct; } // replies_count counts only public and unlisted replies, as Mastodon does: a count must not tell anyone that a // followers-only, direct or circle reply exists public static bool Reply(PostEntity reply) => reply.Visibility is PostVisibility.Public or PostVisibility.Unlisted; public static async Task PostsBy(LocalActor actor, CancellationToken token) => await DB.Default.CountAsync(By(actor), token); // Personas that do not count as this server's users, and are not found in search: those of a banned or deleted root. public static async Task> BarredPersonas(CancellationToken token) { var roots = (await DB.Default.Find().Match(u => u.IsBanned || u.DeletedAt != null).Project(u => u.Include(x => x.ID)).ExecuteAsync(token)) .Select(u => u.ID).ToList(); return roots.Count == 0 ? new List() : (await DB.Default.Find().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList(); } public static async Task Users(CancellationToken token) { var barred = await BarredPersonas(token); return await DB.Default.CountAsync(a => !a.DeletionAt.HasValue && !barred.Contains(a.ID), token); } // users who posted within the last days, as NodeInfo's activeMonth and activeHalfyear and the instance API mean it public static async Task ActiveUsers(int days, CancellationToken token) { var barred = await BarredPersonas(token); var since = DateTime.UtcNow.AddDays(-days); var authors = await DB.Default.Find() .Match(p => !p.IsFederatedCopy && p.CreationDate >= since && !barred.Contains(p.GroupUserId)) .Project(p => p.GroupUserId) .ExecuteAsync(token); var distinct = authors.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList(); return distinct.Count == 0 ? 0 : await DB.Default.CountAsync(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token); } public static async Task LocalPosts(CancellationToken token) { var barred = await BarredPersonas(token); return await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token); } } }