using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Models.User; using PrivaPub.StaticServices; namespace PrivaPub.Api.Mastodon.Controllers { // Trends and the directory (owner decision 2026-10-04, back from the cut list), from what PrivaPub already holds // (Trends). The directory lists only accounts that let themselves be discovered. public class TrendsController : MastodonController { readonly Trends _trends; readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; public TrendsController(Trends trends, MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors) { _trends = trends; _mapper = mapper; _dbEntities = dbEntities; _localActors = localActors; } static List History(Trends.Trend trend) => trend.History.Select(h => (object)new { day = new DateTimeOffset(h.Day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(), uses = h.Uses.ToString(), accounts = h.Accounts.ToString() }).ToList(); IEnumerable Window(IEnumerable items, int fallback, int max) => items.Skip(Math.Max(0, Params.Int("offset") ?? 0)).Take(Limit(fallback, max)); [HttpGet("/api/v1/trends"), HttpGet("/api/v1/trends/tags"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Tags(CancellationToken token) { var followed = MyId == default ? new HashSet() : (await DB.Default.Find().Match(t => t.AvatarId == MyId).ExecuteAsync(token)).Select(t => t.Name).ToHashSet(); return Json(Window(await _trends.Tags(token), 10, 20).Select(t => new { name = t.Key, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(t.Key)}", history = History(t), following = MyId == default ? default(bool?) : followed.Contains(t.Key) }).ToList()); } [HttpGet("/api/v1/trends/statuses"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Statuses(CancellationToken token) { var ids = Window(await _trends.Statuses(token), 20, 40).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID); return Json(await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token)); } [HttpGet("/api/v1/trends/links"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Links(CancellationToken token) { var trends = Window(await _trends.Links(token), 10, 20).ToList(); var sampleIds = trends.Select(t => t.SamplePostId).ToList(); var samples = (await _dbEntities.Posts.Match(p => sampleIds.Contains(p.ID)).ExecuteAsync(token)).ToDictionary(p => p.ID); return Json(trends.Where(t => samples.ContainsKey(t.SamplePostId)).Select(t => { var link = samples[t.SamplePostId].Link; var card = _mapper.LinkCard(t.Key, link.Title, link.Description, link.ImageURL); return card == default ? default : (object)new { card.Url, card.Title, card.Description, card.Type, card.AuthorName, card.AuthorUrl, card.ProviderName, card.ProviderUrl, card.Html, card.Width, card.Height, card.Image, card.ImageDescription, card.EmbedUrl, card.Blurhash, card.Language, card.PublishedAt, card.Authors, History = History(t) }; }).Where(c => c != default).ToList()); } // order=active: by their latest public post PrivaPub holds; order=new: by when they say they joined, which for a // persona is its published day (never its creation, so personas made together are not told apart by their order) [HttpGet("/api/v1/directory"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Directory(CancellationToken token) { var offset = Math.Max(0, Params.Int("offset") ?? 0); var limit = Limit(40, 80); var localOnly = Params.Bool("local") == true; var barred = await Counted.BarredPersonas(token); var wanted = offset + limit; var ordered = new List(); if (Params.Get("order") == "new") { var personas = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) .Sort(a => a.PublishedOn, Order.Descending).Limit(wanted * 4).ExecuteAsync(token)) .Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName))); var remote = localOnly ? Enumerable.Empty<(string ID, DateTime Joined, string Tie)>() : (await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null) .Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token)) .Select(f => (f.ID, Joined: f.Published.Value.Date, Tie: Tie(f.ActorURI))); ordered = personas.Concat(remote).OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal) .Select(a => a.ID).Skip(offset).Take(limit).ToList(); } else { var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) .Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(); var seen = new HashSet(); var maxId = default(string); for (var round = 0; round < 20 && ordered.Count < wanted; round++) { var page = _dbEntities.Posts.Match(p => p.Visibility == Models.Post.PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); if (maxId != default) page.Match(f => f.Lt(p => p.ID, maxId)); if (localOnly) page.Match(p => !p.IsFederatedCopy); var batch = await page.Sort(p => p.ID, Order.Descending).Limit(500) .Project(p => p.Include(x => x.ID).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.IsFederatedCopy)) .ExecuteAsync(token); if (batch.Count == 0) break; maxId = batch[^1].ID; var fresh = batch.Select(p => (Id: p.AuthorAccountId ?? p.GroupUserId, p.IsFederatedCopy)).Where(a => a.Id != default && seen.Add(a.Id)).ToList(); var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList(); var discoverableRemote = remoteIds.Count == 0 ? new HashSet() : (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue) .Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet(); ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id)); } ordered = ordered.Skip(offset).Take(limit).ToList(); } var accounts = await _mapper.Accounts(ordered, token); return Json(ordered.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList()); } static string Tie(string name) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name ?? string.Empty))); } }