trends/tags, trends/statuses and trends/links replace the stubs. Hashtags and links rise with the number of different authors using them today against the week before, two at least; posts with their favourites, boosts and replies, halving every twelve hours. Only public posts PrivaPub already holds count, nothing behind a content warning, and a post trends only if its author is discoverable. Computed at most every ten minutes. The directory lists discoverable accounts by their latest public post, or by the day they say they joined: a persona's published day, never its creation, with ties broken by a hash of the name, so personas made together are not told apart by their order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
127 lines
6.1 KiB
C#
127 lines
6.1 KiB
C#
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<DayUse> History, string SamplePostId);
|
|
|
|
public Task<List<Trend>> 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<List<Trend>> 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<List<string>> 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<List<Post>> Week(System.Linq.Expressions.Expression<Func<Post, bool>> 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<Trend> 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<HashSet<string>> Discoverable(IEnumerable<string> 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<Avatar>().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<ForeignAvatar>().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();
|
|
}
|
|
}
|
|
}
|