Conversations page, and know what is unread
/api/v1/conversations answered one page, never unread, its read endpoint did nothing, and DELETE was missing; each conversation cost a query per member. Now each conversation keeps its newest post (DmGroup.LastPostId, set as posts arrive, learnt once by migration _013) and pages by it as Mastodon does, and each persona's ConversationState holds what it read and what it took off its list: - unread when someone else wrote last, after what the persona read; - read marks it so, and writing in a conversation reads it; - DELETE takes it off the list until a newer message brings it back. The list reads its states, newest posts, members and accounts in a few queries per page. 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
ea5e607779
commit
17922f4dfd
11 files changed
+214
-27
No files matched your search
@@ -6,6 +6,8 @@ using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -83,41 +85,79 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
// the persona's direct conversations, by their newest post (paged like Mastodon's by that post's id), each with its
|
||||
// other members and whether its newest post, someone else's, is newer than what the persona read; the ones it took
|
||||
// off its list stay off until a newer message
|
||||
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Conversations(CancellationToken token)
|
||||
{
|
||||
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
|
||||
var conversations = new List<Conversation>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
||||
if (last == default)
|
||||
continue;
|
||||
var others = new List<string>();
|
||||
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
|
||||
others.Add(member.IsForeign
|
||||
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
|
||||
: member.AvatarId);
|
||||
var accounts = await _mapper.Accounts(others, token);
|
||||
conversations.Add(new Conversation
|
||||
{
|
||||
Id = group.ID,
|
||||
Accounts = accounts.Values.ToList(),
|
||||
LastStatus = await _mapper.Status(last, MyId, token)
|
||||
});
|
||||
}
|
||||
return Json(conversations);
|
||||
var groups = await Page.From(Params, Limit(20, 40)).Fetch(
|
||||
_dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
|
||||
g => g.LastPostId, token);
|
||||
Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
|
||||
return Json(await ConversationsOf(groups, token));
|
||||
}
|
||||
|
||||
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
|
||||
{
|
||||
var ids = groups.Select(g => g.ID).ToList();
|
||||
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
||||
.ToDictionary(s => s.ConversationId);
|
||||
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
|
||||
|
||||
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
|
||||
var newestIds = groups.Select(g => g.LastPostId).ToList();
|
||||
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ConversationId);
|
||||
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
|
||||
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
||||
.ExecuteFirstAsync(token) is { } shown)
|
||||
newest[group.ID] = shown;
|
||||
|
||||
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
|
||||
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
||||
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
|
||||
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
|
||||
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
|
||||
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
||||
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
|
||||
|
||||
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
|
||||
.Select(g => new Conversation
|
||||
{
|
||||
Id = g.ID,
|
||||
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
|
||||
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
|
||||
LastStatus = statuses[newest[g.ID].ID]
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task<DmGroup> MyConversation(string id, CancellationToken token) =>
|
||||
await _dbEntities.DmGroups.Match(g => g.ID == id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.ExecuteFirstAsync(token);
|
||||
|
||||
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
||||
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
|
||||
return Json(conversation ?? new Conversation { Id = group.ID });
|
||||
}
|
||||
|
||||
// off the persona's list until a newer message; the messages themselves stay
|
||||
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
await ConversationStates.Hide(MyId, group.ID, group.LastPostId, token);
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
||||
|
||||
Reference in new issue
Block a user