From 83d4148de1004cc478021bb494f660d2b7e43ab3 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 03:14:56 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonInstanceTests.cs | 4 - PrivaPub.Tests/Http/MastodonTrendsTests.cs | 114 ++++++++++++++ .../Mastodon/Controllers/SearchController.cs | 15 -- .../Mastodon/Controllers/TrendsController.cs | 145 ++++++++++++++++++ .../Api/Mastodon/Mappers/MastodonMapper.cs | 2 + PrivaPub/Domain/Social/Trends.cs | 126 +++++++++++++++ PrivaPub/Program.cs | 1 + docs/ROADMAP.md | 8 +- 8 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 PrivaPub.Tests/Http/MastodonTrendsTests.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/TrendsController.cs create mode 100644 PrivaPub/Domain/Social/Trends.cs diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index fdf874a..4f16299 100644 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs @@ -69,10 +69,6 @@ namespace PrivaPub.Tests.Http [Theory] [InlineData("/api/v1/custom_emojis")] [InlineData("/api/v1/announcements")] - [InlineData("/api/v1/trends")] - [InlineData("/api/v1/trends/tags")] - [InlineData("/api/v1/trends/statuses")] - [InlineData("/api/v1/trends/links")] public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path) { Assert.Empty((await _host.Client().Get(path)).Ok().Array); diff --git a/PrivaPub.Tests/Http/MastodonTrendsTests.cs b/PrivaPub.Tests/Http/MastodonTrendsTests.cs new file mode 100644 index 0000000..777c0b9 --- /dev/null +++ b/PrivaPub.Tests/Http/MastodonTrendsTests.cs @@ -0,0 +1,114 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +namespace PrivaPub.Tests.Http +{ + // Trends from what PrivaPub holds (hashtags and links by how many authors use them, posts by their interactions, + // from discoverable authors only) and the directory of accounts that let themselves be discovered + [Trait("Category", "Integration")] + public sealed class MastodonTrendsTests : IAsyncLifetime + { + PrivaPubHost _host; + + static CancellationToken Token => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + void Fresh() => _host.Get().Forget(); + + static string NewTag() => $"tr{Guid.NewGuid():N}"[..12]; + + [Fact] + public async Task A_hashtag_trends_once_two_authors_use_it_today_and_never_from_one_or_behind_a_warning() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var carol = await _host.Mastodon("carol"); + var shared = NewTag(); + var lonely = NewTag(); + var warned = NewTag(); + await alice.Status($"#{shared} is good"); + await bob.Status($"#{shared} indeed"); + for (var i = 0; i < 4; i++) + await carol.Status($"#{lonely} again {i}"); + await alice.Status($"#{warned}", ("spoiler_text", "careful")); + await bob.Status($"#{warned}", ("spoiler_text", "careful")); + Fresh(); + + var trends = (await _host.Client().Get("/api/v1/trends/tags?limit=20")).Ok().Array; + var trend = Assert.Single(trends, t => t.Text("name") == shared)!; + Assert.Equal(("2", "2"), (trend["history"]![0].Text("uses"), trend["history"]![0].Text("accounts"))); + Assert.DoesNotContain(trends, t => t.Text("name") == lonely); + Assert.DoesNotContain(trends, t => t.Text("name") == warned); + Assert.Contains((await alice.Client.Get("/api/v1/trends?limit=20")).Ok().Array, t => t.Text("name") == shared); + } + + [Fact] + public async Task A_post_trends_by_its_interactions_when_its_author_is_discoverable() + { + var alice = await _host.Mastodon("alice"); + var hidden = await _host.Mastodon("hidden"); + var bob = await _host.Mastodon("bob"); + (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); + var liked = (await alice.Status("worth a like")).Text("id"); + var quiet = (await hidden.Status("worth a like too")).Text("id"); + var answer = (await alice.Status("a reply", ("in_reply_to_id", liked))).Text("id"); + foreach (var id in new[] { liked, quiet, answer }) + { + (await bob.Client.Post($"/api/v1/statuses/{id}/favourite")).Ok(); + (await bob.Client.Post($"/api/v1/statuses/{id}/reblog")).Ok(); + } + Fresh(); + + var trending = (await _host.Client().Get("/api/v1/trends/statuses?limit=40")).Ok().Ids.ToList(); + Assert.Contains(liked, trending); + Assert.DoesNotContain(quiet, trending); + Assert.DoesNotContain(answer, trending); + } + + [Fact] + public async Task A_link_trends_once_two_authors_share_it() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var url = $"https://news.example/{Guid.NewGuid():N}"; + foreach (var account in new[] { alice, bob }) + { + var id = (await account.Status($"read {url}")).Text("id"); + await DB.Default.Update().MatchID(id).Modify(p => p.Link, new PostLink { Href = url, Title = "The news" }).ExecuteAsync(Token); + } + Fresh(); + + var link = Assert.Single((await _host.Client().Get("/api/v1/trends/links?limit=20")).Ok().Array, l => l.Text("url") == url)!; + Assert.Equal("The news", link.Text("title")); + Assert.Equal("2", link["history"]![0].Text("accounts")); + } + + [Fact] + public async Task The_directory_lists_discoverable_accounts_by_activity_or_by_joining_and_never_by_creation() + { + var alice = await _host.Mastodon("alice"); + var hidden = await _host.Mastodon("hidden"); + (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); + await hidden.Status("not listed"); + await alice.Status("listed"); + + var active = (await _host.Client().Get("/api/v1/directory?order=active&local=true&limit=80")).Ok().Ids.ToList(); + Assert.Contains(alice.Id, active); + Assert.DoesNotContain(hidden.Id, active); + var joined = (await _host.Client().Get("/api/v1/directory?order=new&local=true&limit=80")).Ok().Ids.ToList(); + Assert.Contains(alice.Id, joined); + Assert.DoesNotContain(hidden.Id, joined); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 35e1755..9e61c2b 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -87,18 +87,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult Announcements() => Json(Array.Empty()); - [HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult Trends() => Json(Array.Empty()); - - [HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult TrendTags() => Json(Array.Empty()); - - [HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult TrendStatuses() => Json(Array.Empty()); - - [HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult TrendLinks() => Json(Array.Empty()); - [HttpGet("/api/v1/suggestions"), Scope("read")] public IActionResult SuggestionsV1() => Json(Array.Empty()); @@ -126,9 +114,6 @@ namespace PrivaPub.Api.Mastodon.Controllers // Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a // 404 breaks some of them (Mastodon invariant 5) - [HttpGet("/api/v1/directory"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult Directory() => Json(Array.Empty()); - [HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult LinkTimeline() => Json(Array.Empty()); diff --git a/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs new file mode 100644 index 0000000..0fee6bd --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs @@ -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 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))); + } +} diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 04bd6b0..ccffd91 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -523,6 +523,8 @@ namespace PrivaPub.Api.Mastodon.Mappers return default; } + public PreviewCard LinkCard(string url, string title, string description, string image) => Card(url, title, description, image); + PreviewCard Card(string url, string title, string description, string image) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) diff --git a/PrivaPub/Domain/Social/Trends.cs b/PrivaPub/Domain/Social/Trends.cs new file mode 100644 index 0000000..1a987b6 --- /dev/null +++ b/PrivaPub/Domain/Social/Trends.cs @@ -0,0 +1,126 @@ +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(); + } + } +} diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 85597a3..9382177 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -63,6 +63,7 @@ try .PrivaPubOAuth(builder.Environment) .AddScoped() .AddScoped() + .AddSingleton() .Configure(builder.Configuration.GetSection("Media")) .AddSingleton() .AddSingleton() diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 58339c3..b6e1e07 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,6 +74,11 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [x] Followed hashtags: `tags/:name` with its week of public use, follow and unfollow, `followed_tags`; a public post (not a boost or a reply) PrivaPub holds comes to the homes of those following one of its tags. Nothing is fetched for a followed tag, and no other server hears of it. + - [x] Trends and the directory: hashtags and links rise with the number of different authors using them today against + the week before (two at least), posts with their interactions halving every twelve hours; only public posts PrivaPub + holds count, nothing behind a content warning, and a post trends only if its author is discoverable. 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, so personas made together are not told apart by their order). ## Intent @@ -461,7 +466,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol - **Filters:** v2 with keywords and statuses, v1, `filtered` on statuses (P9). - **Scheduled statuses:** `scheduled_at` on posting, `scheduled_statuses` (P9). - **Tags:** `tags/:name` with history, follow and unfollow, `followed_tags` (P9). - - **Stubs:** `custom_emojis`, announcements, trends, suggestions, preferences. + - **Trends and directory:** `trends/tags`, `trends/statuses`, `trends/links`, `directory` (P9). + - **Stubs:** `custom_emojis`, announcements, suggestions, preferences. - **Mapping:** - Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.