[backend/api] Add all missing timeline endpoints

This commit is contained in:
Laura Hausmann 2025-03-22 23:35:51 +01:00
parent d2f1048dcc
commit 2e911805de
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
7 changed files with 191 additions and 9 deletions

View file

@ -42,4 +42,94 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Home);
}
[HttpGet("local")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetLocalTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => p.UserHost == null)
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("social")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetSocialTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var heuristic = await QueryableTimelineExtensions.GetHeuristicAsync(user, db, cache);
var notes = await db.Notes.IncludeCommonProperties()
.FilterByFollowingOwnAndLocal(user, db, heuristic)
.EnsureVisibleFor(user)
.FilterHidden(user, db, filterHiddenListMembers: true)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("recommended")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetRecommendedTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => db.RecommendedInstances.Any(i => i.Host == p.UserHost))
.EnsureVisibleFor(user)
.FilterHidden(user, db, filterHiddenListMembers: true)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("global")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetGlobalTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("remote/{instance}")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetRemoteTimeline(string instance, PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => p.UserHost == instance)
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
}

View file

@ -92,6 +92,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<Filter> Filters { get; init; } = null!;
public virtual DbSet<PluginStoreEntry> PluginStore { get; init; } = null!;
public virtual DbSet<PolicyConfiguration> PolicyConfiguration { get; init; } = null!;
public virtual DbSet<RecommendedInstance> RecommendedInstances { get; init; } = null!;
public virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection config)

View file

@ -3651,6 +3651,18 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("push_subscription");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RecommendedInstance", b =>
{
b.Property<string>("Host")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("host");
b.HasKey("Host");
b.ToTable("recommended_instance");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RegistrationInvite", b =>
{
b.Property<string>("Id")

View file

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250322222922_AddRecommendedInstanceTable")]
public partial class AddRecommendedInstanceTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "recommended_instance",
columns: table => new
{
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_recommended_instance", x => x.host);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "recommended_instance");
}
}
}

View file

@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("recommended_instance")]
public class RecommendedInstance
{
[Key]
[Column("host")]
[StringLength(256)]
public string Host { get; set; } = null!;
}

View file

@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Extensions;
@ -18,16 +20,25 @@ public static class QueryableTimelineExtensions
)
{
return heuristic < Cutoff
? query.FollowingAndOwnLowFreq(user, db)
? query.Where(FollowingAndOwnLowFreqExpr(user, db))
: query.Where(note => note.User == user || note.User.IsFollowedBy(user));
}
private static IQueryable<Note> FollowingAndOwnLowFreq(this IQueryable<Note> query, User user, DatabaseContext db)
=> query.Where(note => db.Followings
.Where(p => p.Follower == user)
.Select(p => p.FolloweeId)
.Concat(new[] { user.Id })
.Contains(note.UserId));
public static IQueryable<Note> FilterByFollowingOwnAndLocal(
this IQueryable<Note> query, User user, DatabaseContext db, int heuristic
)
{
return heuristic < Cutoff
? query.Where(FollowingAndOwnLowFreqExpr(user, db).Or(p => p.UserHost == null))
: query.Where(note => note.User == user || note.User.IsFollowedBy(user));
}
private static Expression<Func<Note,bool>> FollowingAndOwnLowFreqExpr(User user, DatabaseContext db)
=> note => db.Followings
.Where(p => p.Follower == user)
.Select(p => p.FolloweeId)
.Concat(new[] { user.Id })
.Contains(note.UserId);
public static IQueryable<User> NeedsTimelineHeuristicUpdate(
this IQueryable<User> query, DatabaseContext db, TimeSpan maxRemainingTtl
@ -60,7 +71,7 @@ public static class QueryableTimelineExtensions
//TODO: maybe we should express this as a ratio between matching and non-matching posts
return await db.Notes
.Where(p => p.CreatedAt > latestNote.CreatedAt - TimeSpan.FromDays(7))
.FollowingAndOwnLowFreq(user, db)
.Where(FollowingAndOwnLowFreqExpr(user, db))
.OrderByDescending(p => p.Id)
.Take(Cutoff + 1)
.CountAsync();

View file

@ -9,4 +9,24 @@ internal class TimelineControllerModel(ApiClient api)
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetHomeTimelineAsync(PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, "/timelines/home", pq);
}
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetLocalTimelineAsync(PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, "/timelines/local", pq);
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetSocialTimelineAsync(PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, "/timelines/social", pq);
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetRecommendedTimelineAsync(PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, "/timelines/recommended", pq);
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetGlobalTimelineAsync(PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, "/timelines/global", pq);
[LinkPagination(20, 80)]
public Task<List<NoteResponse>> GetRemoteTimelineAsync(string instance, PaginationQuery pq) =>
api.CallAsync<List<NoteResponse>>(HttpMethod.Get, $"/timelines/remote/{instance}", pq);
}