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.