diff --git a/.gitignore b/.gitignore index 2266c950..a5e74c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ Iceshrimp.Frontend/.yarn Iceshrimp.Frontend/.pnp.* *.DotSettings.user + +TestResults/ diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs index 4d762dcc..73124dc9 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs @@ -23,13 +23,12 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen [HttpGet("home")] [Produces("application/json")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetHomeTimeline() { + public async Task GetHomeTimeline(PaginationQuery query) { var user = HttpContext.GetOauthUser() ?? throw new GracefulException("Failed to get user from HttpContext"); var res = await db.Notes .WithIncludes() .FilterByFollowingAndOwn(user) - .OrderByIdDesc() - .Take(40) + .Paginate(query, 20, 40) .RenderAllForMastodonAsync(noteRenderer); return Ok(res); @@ -39,13 +38,12 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen [HttpGet("public")] [Produces("application/json")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetPublicTimeline() { + public async Task GetPublicTimeline(PaginationQuery query) { var res = await db.Notes - .WithIncludes() - .HasVisibility(Note.NoteVisibility.Public) - .OrderByIdDesc() - .Take(40) - .RenderAllForMastodonAsync(noteRenderer); + .WithIncludes() + .HasVisibility(Note.NoteVisibility.Public) + .Paginate(query, 20, 40) + .RenderAllForMastodonAsync(noteRenderer); return Ok(res); } diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PaginationQuery.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PaginationQuery.cs new file mode 100644 index 00000000..3a45c9d8 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PaginationQuery.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; +using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; + +public class PaginationQuery { + [FromQuery] [B(Name = "max_id")] public string? MaxId { get; set; } + [FromQuery] [B(Name = "since_id")] public string? SinceId { get; set; } + [FromQuery] [B(Name = "min_id")] public string? MinId { get; set; } + [FromQuery] [B(Name = "limit")] public int? Limit { get; set; } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs index 4d239cfd..99e7613f 100644 --- a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs @@ -1,6 +1,8 @@ using Iceshrimp.Backend.Controllers.Mastodon.Renderers; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Core.Extensions; @@ -14,6 +16,30 @@ public static class NoteQueryableExtensions { .ThenInclude(p => p != null ? p.User : null); } + public static IQueryable Paginate(this IQueryable query, PaginationQuery p, int defaultLimit, + int maxLimit) { + if (p is { SinceId: not null, MinId: not null }) + throw GracefulException.BadRequest("Can't use sinceId and minId params simultaneously"); + + query = p switch { + { SinceId: not null, MaxId: not null } => query + .Where(note => note.Id.IsGreaterThan(p.SinceId) && + note.Id.IsLessThan(p.MaxId)) + .OrderByDescending(note => note.Id), + { MinId: not null, MaxId: not null } => query + .Where(note => note.Id.IsGreaterThan(p.MinId) && + note.Id.IsLessThan(p.MaxId)) + .OrderBy(note => note.Id), + { SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(p.SinceId)) + .OrderByDescending(note => note.Id), + { MinId: not null } => query.Where(note => note.Id.IsGreaterThan(p.MinId)).OrderBy(note => note.Id), + { MaxId: not null } => query.Where(note => note.Id.IsLessThan(p.MaxId)).OrderByDescending(note => note.Id), + _ => query + }; + + return query.Take(Math.Min(p.Limit ?? defaultLimit, maxLimit)); + } + public static IQueryable HasVisibility(this IQueryable query, Note.NoteVisibility visibility) { return query.Where(note => note.Visibility == visibility); } diff --git a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs index a9dca3c6..20a7b80e 100644 --- a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs @@ -1,4 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; +using EntityFrameworkCore.Projectables; namespace Iceshrimp.Backend.Core.Extensions; @@ -14,4 +16,27 @@ public static class StringExtensions { public static string FromPunycode(this string target) { return new IdnMapping().GetUnicode(target); } +} + +[SuppressMessage("ReSharper", "StringCompareToIsCultureSpecific")] +public static class ProjectableStringExtensions { + [Projectable] + public static bool IsLessThan(this string a, string b) { + return a.CompareTo(b) < 0; + } + + [Projectable] + public static bool IsLessOrEqualTo(this string a, string b) { + return a.CompareTo(b) <= 0; + } + + [Projectable] + public static bool IsGreaterThan(this string a, string b) { + return a.CompareTo(b) > 0; + } + + [Projectable] + public static bool IsGreaterOrEqualTo(this string a, string b) { + return a.CompareTo(b) >= 0; + } } \ No newline at end of file