[backend/mastodon-client] Implement conversations endpoints
This commit is contained in:
parent
fa7b98c366
commit
82ff329e34
6 changed files with 6408 additions and 1 deletions
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
6149
Iceshrimp.Backend/Core/Database/Migrations/20240226234449_AddConversationsFunction.Designer.cs
generated
Normal file
6149
Iceshrimp.Backend/Core/Database/Migrations/20240226234449_AddConversationsFunction.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue