using Microsoft.Extensions.Caching.Memory; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.StaticServices; namespace PrivaPub.Domain.Social { // What is being talked about here (owner decision 2026-10-04: trends count only public posts PrivaPub already holds, // and fetch nothing). Hashtags and links rise with the number of different authors using them today against the // week before; posts with their favourites, boosts and replies, halving every twelve hours. A post trends only if its // author is discoverable, and nothing warned about (content warning) trends. Computed at most every ten minutes. public sealed class Trends { public static readonly TimeSpan Fresh = TimeSpan.FromMinutes(10); const int MinAuthors = 2;//one account alone never makes a trend const int Kept = 40; readonly DbEntities _dbEntities; readonly IMemoryCache _cache; public Trends(DbEntities dbEntities, IMemoryCache cache) { _dbEntities = dbEntities; _cache = cache; } // what is computed is forgotten: the next read computes it again public void Forget() { foreach (var key in new[] { "trends:tags", "trends:links", "trends:statuses" }) _cache.Remove(key); } public sealed record DayUse(DateTime Day, int Uses, int Accounts); public sealed record Trend(string Key, List History, string SamplePostId); public Task> Tags(CancellationToken token) => _cache.GetOrCreateAsync("trends:tags", async entry => { entry.AbsoluteExpirationRelativeToNow = Fresh; var posts = await Week(p => p.Tags.Count > 0, token); return Rank(posts.SelectMany(p => p.Tags.Distinct().Select(t => (Key: t, Post: p)))); }); public Task> Links(CancellationToken token) => _cache.GetOrCreateAsync("trends:links", async entry => { entry.AbsoluteExpirationRelativeToNow = Fresh; var posts = await Week(p => p.Link != null, token); return Rank(posts.Where(p => Uri.TryCreate(p.Link.Href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") .Select(p => (Key: Normal(p.Link.Href), Post: p))); }); // the ids of the trending posts, best first public Task> Statuses(CancellationToken token) => _cache.GetOrCreateAsync("trends:statuses", async entry => { entry.AbsoluteExpirationRelativeToNow = Fresh; var now = DateTime.UtcNow; var since = now.AddHours(-48); var posts = await _dbEntities.Posts .Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly && p.ReblogOfPostId == null && p.InReplyToURI == null && p.AnsweringToPostId == null && p.CreationDate >= since && p.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0) .ExecuteAsync(token); var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token); return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId)) .Select(p => (p.ID, Score: (p.FavouritesCount + 2.0 * p.ReblogsCount + p.RepliesCount) * Math.Pow(0.5, (now - p.CreationDate).TotalHours / 12))) .OrderByDescending(p => p.Score).Take(Kept).Select(p => p.ID).ToList(); }); Task> Week(System.Linq.Expressions.Expression> with, CancellationToken token) { var since = DateTime.UtcNow.Date.AddDays(-6); return _dbEntities.Posts .Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly && p.ReblogOfPostId == null && p.CreationDate >= since) .Match(with) .Project(p => p.Include(x => x.ID).Include(x => x.Tags).Include(x => x.Link).Include(x => x.CreationDate) .Include(x => x.AuthorAccountId).Include(x => x.GroupUserId)) .ExecuteAsync(token); } // rising: today's authors squared over the week's daily average before today, so a new subject beats a steady one static List Rank(IEnumerable<(string Key, Post Post)> uses) { var today = DateTime.UtcNow.Date; return uses.GroupBy(u => u.Key) .Select(g => { var history = Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day => { var that = g.Where(u => u.Post.CreationDate >= day && u.Post.CreationDate < day.AddDays(1)).ToList(); return new DayUse(day, that.Count, that.Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count()); }).ToList(); var recentAuthors = g.Where(u => u.Post.CreationDate >= today.AddDays(-1)).Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count(); var before = history.Skip(1).Average(h => h.Accounts); return (Trend: new Trend(g.Key, history, g.OrderByDescending(u => u.Post.CreationDate).First().Post.ID), Recent: recentAuthors, Score: recentAuthors * recentAuthors / (before + 1)); }) .Where(t => t.Recent >= MinAuthors) .OrderByDescending(t => t.Score).ThenByDescending(t => t.Recent).ThenBy(t => t.Trend.Key, StringComparer.Ordinal) .Take(Kept).Select(t => t.Trend).ToList(); } static string Normal(string href) => Uri.TryCreate(href, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Query).TrimEnd('/') : href; // the accounts among these whose owners let them be discovered: personas by their setting, remote accounts by theirs static async Task> Discoverable(IEnumerable accountIds, CancellationToken token) { var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); var barred = await Privacy.Counted.BarredPersonas(token); var local = await DB.Default.Find().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) .Project(a => a.Include(x => x.ID)).ExecuteAsync(token); var remote = await DB.Default.Find().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue) .Project(f => f.Include(x => x.ID)).ExecuteAsync(token); return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet(); } } }