Trends and the directory, from what PrivaPub holds
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
This commit is contained in:
1 parent
5f72435bfb
commit
83d4148de1
8 files changed
+395
-20
No files matched your search
@@ -0,0 +1,145 @@
|
||||
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<object> 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<T> Window<T>(IEnumerable<T> 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<IActionResult> Tags(CancellationToken token)
|
||||
{
|
||||
var followed = MyId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Models.Social.FollowedTag>().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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>();
|
||||
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<string>();
|
||||
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<string>()
|
||||
: (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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user