Fanout writes a TimelineEntry for every persona a post should reach: the author, local followers of a local author, local followers of a remote one, the members of a direct conversation or a circle. Mastodon's home rules apply when it is written: a reply shows only to followers of both sides (or to the one replied to), a reblog only where reblogs are wanted. A mention of a local persona becomes a Mention notification. Inbound posts are now also kept when a persona follows their author. RemotePosts holds what CreateHandler and backfill share: building a Post from a note, and fetching a public parent the first time a reply to it arrives, so its author is known (a reply to an unknown or non-public parent stays out of home timelines). Each fetched ancestor queues a FetchAncestors job for the next one, up to ten deep. /clientapi/timeline/home and /clientapi/notifications (with /notifications/read) page by max_id for the persona's own root only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
120 lines
4.4 KiB
C#
120 lines
4.4 KiB
C#
using Microsoft.Extensions.Localization;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Post;
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.StaticServices;
|
|
|
|
namespace PrivaPub.Domain.Timelines
|
|
{
|
|
public interface ITimelineService
|
|
{
|
|
Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
|
|
Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
|
|
Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token);
|
|
}
|
|
|
|
public class TimelineService : ITimelineService
|
|
{
|
|
const int MaxLimit = 40;
|
|
|
|
readonly DbEntities _dbEntities;
|
|
readonly IStringLocalizer<GenericRes> _localizer;
|
|
|
|
public TimelineService(DbEntities dbEntities, IStringLocalizer<GenericRes> localizer)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localizer = localizer;
|
|
}
|
|
|
|
public async Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!await Owns(rootUserId, avatarId, token))
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
|
|
var query = _dbEntities.TimelineEntries.Match(e => e.AvatarId == avatarId);
|
|
if (!string.IsNullOrEmpty(maxId))
|
|
query.Match(f => f.Lt(e => e.PostId, maxId));
|
|
var entries = await query.Sort(e => e.PostId, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token);
|
|
|
|
var ids = entries.Select(e => e.PostId).Concat(entries.Where(e => e.ReblogOfPostId != default).Select(e => e.ReblogOfPostId)).Distinct().ToList();
|
|
var posts = ids.Count == 0
|
|
? new Dictionary<string, Post>()
|
|
: (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
|
|
result.Data = entries
|
|
.Where(e => posts.ContainsKey(e.PostId) && (e.ReblogOfPostId == default || posts.ContainsKey(e.ReblogOfPostId)))
|
|
.Select(e =>
|
|
{
|
|
var view = ToView(posts[e.PostId]);
|
|
if (e.ReblogOfPostId != default)
|
|
view.ReblogOf = ToView(posts[e.ReblogOfPostId]);
|
|
return view;
|
|
})
|
|
.ToList();
|
|
return result;
|
|
}
|
|
|
|
public async Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!await Owns(rootUserId, avatarId, token))
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
|
|
var query = _dbEntities.Notifications.Match(n => n.AvatarId == avatarId);
|
|
if (!string.IsNullOrEmpty(maxId))
|
|
query.Match(f => f.Lt(n => n.ID, maxId));
|
|
result.Data = (await query.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token))
|
|
.Select(n => new ViewNotification
|
|
{
|
|
Id = n.ID,
|
|
Type = n.Type.ToString(),
|
|
FromAccountId = n.FromAccountId,
|
|
FromActorURI = n.FromActorURI,
|
|
PostId = n.PostId,
|
|
IsRead = n.IsRead,
|
|
CreatedAt = n.CreatedAt
|
|
})
|
|
.ToList();
|
|
return result;
|
|
}
|
|
|
|
public async Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!await Owns(rootUserId, avatarId, token))
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
await DB.Default.Update<Notification>().Match(n => n.AvatarId == avatarId && !n.IsRead).Modify(n => n.IsRead, true).ExecuteAsync(token);
|
|
return result;
|
|
}
|
|
|
|
async Task<bool> Owns(string rootUserId, string avatarId, CancellationToken token) =>
|
|
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
|
|
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
|
|
|
|
static ViewPost ToView(Post post) => new()
|
|
{
|
|
Id = post.ID,
|
|
ObjectURI = post.ObjectURI,
|
|
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
|
|
AuthorActorURI = post.ActorURI,
|
|
GroupId = post.GroupId,
|
|
DmGroupId = post.ConversationId,
|
|
Visibility = post.Visibility.ToString(),
|
|
AnsweringToPostId = post.AnsweringToPostId,
|
|
Title = post.Title,
|
|
Text = post.Text,
|
|
ContentHtml = post.ContentHtml,
|
|
HasContentWarning = post.HasContentWarning,
|
|
IsFederatedCopy = post.IsFederatedCopy,
|
|
CreationDate = post.CreationDate
|
|
};
|
|
}
|
|
}
|