[backend/mastodon-client] Implement conversations endpoints

This commit is contained in:
Laura Hausmann 2024-02-26 23:34:56 +01:00
parent fa7b98c366
commit 82ff329e34
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 6408 additions and 1 deletions

View file

@ -0,0 +1,151 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[MastodonApiController]
[Route("/api/v1/conversations")]
[Authenticate]
[EnableRateLimiting("sliding")]
[EnableCors("mastodon")]
[Produces(MediaTypeNames.Application.Json)]
public class ConversationsController(
DatabaseContext db,
NoteRenderer noteRenderer,
UserRenderer userRenderer
) : ControllerBase
{
[HttpGet]
[Authorize("read:statuses")]
[LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ConversationEntity>))]
public async Task<IActionResult> GetConversations(MastodonPaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
//TODO: rewrite using .DistinctBy when https://github.com/npgsql/efcore.pg/issues/894 is implemented
var conversations = await db.Conversations(user)
.IncludeCommonProperties()
.Paginate(p => p.ThreadId ?? p.Id, pq, ControllerContext)
.Select(p => new Conversation
{
Id = p.ThreadId ?? p.Id,
LastNote = p,
Users = db.Users.IncludeCommonProperties()
.Where(u => p.VisibleUserIds.Contains(u.Id) || u.Id == p.UserId)
.ToList(),
Unread = db.Notifications.Any(n => n.Note == p &&
n.Notifiee == user &&
!n.IsRead &&
(n.Type ==
Notification.NotificationType.Reply ||
n.Type ==
Notification.NotificationType.Mention))
})
.ToListAsync();
var accounts = (await userRenderer.RenderManyAsync(conversations.SelectMany(p => p.Users)))
.DistinctBy(p => p.Id)
.ToList();
var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts);
var res = conversations.Select(p => new ConversationEntity
{
Id = p.Id,
Unread = p.Unread,
LastStatus = notes.First(n => n.Id == p.LastNote.Id),
Accounts = accounts.Where(a => p.Users.Any(u => u.Id == a.Id))
.DefaultIfEmpty(accounts.First(a => a.Id == user.Id))
.ToList()
});
return Ok(res);
}
[HttpDelete("{id}")]
[Authorize("write:conversations")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))]
public IActionResult RemoveConversation(string id) => throw new GracefulException(HttpStatusCode.NotImplemented,
"Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon");
[HttpPost("{id}/read")]
[Authorize("write:conversations")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ConversationEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> MarkRead(string id)
{
var user = HttpContext.GetUserOrFail();
var conversation = await db.Conversations(user)
.IncludeCommonProperties()
.Where(p => (p.ThreadId ?? p.Id) == id)
.Select(p => new Conversation
{
Id = p.ThreadId ?? p.Id,
LastNote = p,
Users = db.Users.IncludeCommonProperties()
.Where(u => p.VisibleUserIds.Contains(u.Id) || u.Id == p.UserId)
.ToList(),
Unread = db.Notifications.Any(n => n.Note == p &&
n.Notifiee == user &&
!n.IsRead &&
(n.Type ==
Notification.NotificationType.Reply ||
n.Type ==
Notification.NotificationType.Mention))
})
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (conversation.Unread)
{
await db.Notifications.Where(n => n.Note == conversation.LastNote &&
n.Notifiee == user &&
!n.IsRead &&
(n.Type ==
Notification.NotificationType.Reply ||
n.Type ==
Notification.NotificationType.Mention))
.ExecuteUpdateAsync(p => p.SetProperty(n => n.IsRead, true));
conversation.Unread = false;
}
var accounts = (await userRenderer.RenderManyAsync(conversation.Users))
.DistinctBy(p => p.Id)
.ToList();
var noteRendererDto = new NoteRenderer.NoteRendererDto { Accounts = accounts };
var res = new ConversationEntity
{
Id = conversation.Id,
Unread = conversation.Unread,
LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, noteRendererDto),
Accounts = accounts
};
return Ok(res);
}
private class Conversation
{
public required string Id { get; init; }
public required Note LastNote;
public required List<User> Users;
public required bool Unread;
}
}

View file

@ -0,0 +1,12 @@
using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class ConversationEntity : IEntity
{
[J("id")] public required string Id { get; set; }
[J("unread")] public required bool Unread { get; set; }
[J("accounts")] public required List<AccountEntity> Accounts { get; set; }
[J("last_status")] public required StatusEntity LastStatus { get; set; }
}

View file

@ -140,6 +140,10 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(NoteDescendants), .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(NoteDescendants),
[typeof(string), typeof(int), typeof(int)])!) [typeof(string), typeof(int), typeof(int)])!)
.HasName("note_descendants"); .HasName("note_descendants");
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(Conversations),
[typeof(string)])!)
.HasName("conversations");
modelBuilder.Entity<AbuseUserReport>(entity => modelBuilder.Entity<AbuseUserReport>(entity =>
{ {
@ -1126,6 +1130,12 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public IQueryable<Note> NoteDescendants(Note note, int depth, int breadth) public IQueryable<Note> NoteDescendants(Note note, int depth, int breadth)
=> FromExpression(() => NoteDescendants(note.Id, depth, breadth)); => FromExpression(() => NoteDescendants(note.Id, depth, breadth));
public IQueryable<Note> Conversations(string userId)
=> FromExpression(() => Conversations(userId));
public IQueryable<Note> Conversations(User user)
=> FromExpression(() => Conversations(user.Id));
} }
[SuppressMessage("ReSharper", "UnusedType.Global", [SuppressMessage("ReSharper", "UnusedType.Global",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddConversationsFunction : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE OR REPLACE FUNCTION public.conversations(user_id character varying)
RETURNS SETOF note
LANGUAGE sql
AS $function$
SELECT DISTINCT ON (COALESCE("threadId", "id")) *
FROM
"note"
WHERE
("visibility" = 'specified' AND ("visibleUserIds" @> array["user_id"]::varchar[] OR "userId" = "user_id"))
$function$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP FUNCTION IF EXISTS public.conversations;");
}
}
}

View file

@ -39,7 +39,7 @@ public static class QueryableExtensions
.Where(p => p.Id.IsGreaterThan(pq.MinId) && .Where(p => p.Id.IsGreaterThan(pq.MinId) &&
p.Id.IsLessThan(pq.MaxId)) p.Id.IsLessThan(pq.MaxId))
.OrderBy(p => p.Id), .OrderBy(p => p.Id),
{ SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(pq.SinceId)) { SinceId: not null } => query.Where(p => p.Id.IsGreaterThan(pq.SinceId))
.OrderByDescending(p => p.Id), .OrderByDescending(p => p.Id),
{ MinId: not null } => query.Where(p => p.Id.IsGreaterThan(pq.MinId)).OrderBy(p => p.Id), { MinId: not null } => query.Where(p => p.Id.IsGreaterThan(pq.MinId)).OrderBy(p => p.Id),
{ MaxId: not null } => query.Where(p => p.Id.IsLessThan(pq.MaxId)).OrderByDescending(p => p.Id), { MaxId: not null } => query.Where(p => p.Id.IsLessThan(pq.MaxId)).OrderByDescending(p => p.Id),
@ -49,6 +49,42 @@ public static class QueryableExtensions
return query.Take(Math.Min(pq.Limit ?? defaultLimit, maxLimit)); return query.Take(Math.Min(pq.Limit ?? defaultLimit, maxLimit));
} }
public static IQueryable<T> Paginate<T>(
this IQueryable<T> query,
Expression<Func<T, string>> predicate,
MastodonPaginationQuery pq,
int defaultLimit,
int maxLimit
) where T : IEntity
{
if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1");
if (pq is { SinceId: not null, MinId: not null })
throw GracefulException.BadRequest("Can't use sinceId and minId params simultaneously");
query = pq switch
{
{ SinceId: not null, MaxId: not null } => query
.Where(predicate.Compose(id => id.IsGreaterThan(pq.SinceId) &&
id.IsLessThan(pq.MaxId)))
.OrderByDescending(predicate),
{ MinId: not null, MaxId: not null } => query
.Where(predicate.Compose(id => id.IsGreaterThan(pq.MinId) &&
id.IsLessThan(pq.MaxId)))
.OrderBy(predicate),
{ SinceId: not null } => query.Where(predicate.Compose(id => id.IsGreaterThan(pq.SinceId)))
.OrderByDescending(predicate),
{ MinId: not null } => query.Where(predicate.Compose(id => id.IsGreaterThan(pq.MinId)))
.OrderBy(predicate),
{ MaxId: not null } => query.Where(predicate.Compose(id => id.IsLessThan(pq.MaxId)))
.OrderByDescending(predicate),
_ => query.OrderByDescending(predicate)
};
return query.Take(Math.Min(pq.Limit ?? defaultLimit, maxLimit));
}
public static IQueryable<T> Paginate<T>( public static IQueryable<T> Paginate<T>(
this IQueryable<T> query, this IQueryable<T> query,
PaginationQuery pq, PaginationQuery pq,
@ -88,6 +124,22 @@ public static class QueryableExtensions
return Paginate(query, pq, filter.DefaultLimit, filter.MaxLimit); return Paginate(query, pq, filter.DefaultLimit, filter.MaxLimit);
} }
public static IQueryable<T> Paginate<T>(
this IQueryable<T> query,
Expression<Func<T, string>> predicate,
MastodonPaginationQuery pq,
ControllerContext context
) where T : IEntity
{
var filter = context.ActionDescriptor.FilterDescriptors.Select(p => p.Filter)
.OfType<LinkPaginationAttribute>()
.FirstOrDefault();
if (filter == null)
throw new GracefulException("Route doesn't have a LinkPaginationAttribute");
return Paginate(query, predicate, pq, filter.DefaultLimit, filter.MaxLimit);
}
public static IQueryable<T> Paginate<T>( public static IQueryable<T> Paginate<T>(
this IQueryable<T> query, this IQueryable<T> query,
PaginationQuery pq, PaginationQuery pq,