[backend/federation] Federate pinned notes (ISH-111)
This commit is contained in:
parent
c9152badbf
commit
4c4488d522
6 changed files with 106 additions and 15 deletions
|
@ -71,6 +71,15 @@ public class AdminController(
|
|||
return await apController.GetUser(id);
|
||||
}
|
||||
|
||||
[UseNewtonsoftJson]
|
||||
[HttpGet("activities/users/{id}/collections/featured")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]
|
||||
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")]
|
||||
public async Task<IActionResult> GetUserFeaturedActivity(string id)
|
||||
{
|
||||
return await apController.GetUserFeatured(id);
|
||||
}
|
||||
|
||||
[UseNewtonsoftJson]
|
||||
[HttpGet("activities/users/@{acct}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]
|
||||
|
|
|
@ -2,6 +2,7 @@ using System.Text;
|
|||
using Iceshrimp.Backend.Controllers.Attributes;
|
||||
using Iceshrimp.Backend.Controllers.Federation.Attributes;
|
||||
using Iceshrimp.Backend.Controllers.Schemas;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
||||
|
@ -11,6 +12,7 @@ using Iceshrimp.Backend.Core.Queues;
|
|||
using Iceshrimp.Backend.Core.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Federation;
|
||||
|
||||
|
@ -21,7 +23,8 @@ public class ActivityPubController(
|
|||
DatabaseContext db,
|
||||
QueueService queues,
|
||||
ActivityPub.NoteRenderer noteRenderer,
|
||||
ActivityPub.UserRenderer userRenderer
|
||||
ActivityPub.UserRenderer userRenderer,
|
||||
IOptions<Config.InstanceSection> config
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("/notes/{id}")]
|
||||
|
@ -58,6 +61,37 @@ public class ActivityPubController(
|
|||
return Ok(compacted);
|
||||
}
|
||||
|
||||
[HttpGet("/users/{id}/collections/featured")]
|
||||
[AuthorizedFetch]
|
||||
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
|
||||
public async Task<IActionResult> GetUserFeatured(string id)
|
||||
{
|
||||
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.Host == null);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
var pins = await db.UserNotePins.Where(p => p.User == user)
|
||||
.OrderByDescending(p => p.Id)
|
||||
.Include(p => p.Note.User.UserProfile)
|
||||
.Include(p => p.Note.Renote!.User.UserProfile)
|
||||
.Include(p => p.Note.Reply!.User.UserProfile)
|
||||
.Select(p => p.Note)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var rendered = await pins.Select(p => noteRenderer.RenderAsync(p)).AwaitAllNoConcurrencyAsync();
|
||||
var res = new ASOrderedCollection
|
||||
{
|
||||
Id = $"{user.GetPublicUri(config.Value)}/collections/featured",
|
||||
TotalItems = (ulong)rendered.Count,
|
||||
Items = rendered.Cast<ASObject>().ToList()
|
||||
};
|
||||
|
||||
var compacted = LdHelpers.Compact(res);
|
||||
return Ok(compacted);
|
||||
}
|
||||
|
||||
[HttpGet("/@{acct}")]
|
||||
[AuthorizedFetch]
|
||||
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
|
||||
|
|
|
@ -63,6 +63,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
|
|||
IsCat = user.IsCat,
|
||||
IsDiscoverable = user.IsExplorable,
|
||||
IsLocked = user.IsLocked,
|
||||
Featured = new ASOrderedCollection($"{id}/collections/featured"),
|
||||
Avatar = user.AvatarUrl != null
|
||||
? new ASImage { Url = new ASLink(user.AvatarUrl) }
|
||||
: null,
|
||||
|
|
|
@ -81,7 +81,7 @@ public sealed class ASCollectionConverter : JsonConverter
|
|||
}
|
||||
}
|
||||
|
||||
internal sealed class ASCollectionItemsConverter : JsonConverter
|
||||
internal class ASCollectionItemsConverter : JsonConverter
|
||||
{
|
||||
public override bool CanWrite => false;
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ using Newtonsoft.Json;
|
|||
using Newtonsoft.Json.Linq;
|
||||
using J = Newtonsoft.Json.JsonPropertyAttribute;
|
||||
using JC = Newtonsoft.Json.JsonConverterAttribute;
|
||||
using JI = Newtonsoft.Json.JsonIgnoreAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||
|
||||
|
@ -16,18 +15,51 @@ public class ASOrderedCollection : ASCollection
|
|||
[SetsRequiredMembers]
|
||||
public ASOrderedCollection(string id, bool withType = false) : this(withType) => Id = id;
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#orderedItems")]
|
||||
[JC(typeof(ASCollectionItemsConverter))]
|
||||
[JI]
|
||||
public List<ASObject>? OrderedItems
|
||||
[J($"{Constants.ActivityStreamsNs}#items")]
|
||||
[JC(typeof(ASOrderedCollectionItemsConverter))]
|
||||
public new List<ASObject>? Items
|
||||
{
|
||||
get => Items;
|
||||
set => Items = value;
|
||||
get => base.Items;
|
||||
set => base.Items = value;
|
||||
}
|
||||
|
||||
public new const string ObjectType = $"{Constants.ActivityStreamsNs}#OrderedCollection";
|
||||
}
|
||||
|
||||
internal sealed class ASOrderedCollectionItemsConverter : ASCollectionItemsConverter
|
||||
{
|
||||
public override bool CanWrite => true;
|
||||
|
||||
public override bool CanConvert(Type objectType) => true;
|
||||
|
||||
public override object? ReadJson(
|
||||
JsonReader reader, Type objectType, object? existingValue,
|
||||
JsonSerializer serializer
|
||||
)
|
||||
{
|
||||
if (reader.TokenType != JsonToken.StartArray) throw new Exception("this shouldn't happen");
|
||||
|
||||
var obj = JArray.Load(reader);
|
||||
return obj.Count == 0
|
||||
? null
|
||||
: obj.SelectToken("$.[*].@list")?.Children().Select(ASObject.Deserialize).OfType<ASObject>().ToList();
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNull();
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("@list");
|
||||
serializer.Serialize(writer, value);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ASOrderedCollectionConverter : JsonConverter
|
||||
{
|
||||
public override bool CanWrite => false;
|
||||
|
|
|
@ -778,6 +778,8 @@ public class NoteService(
|
|||
|
||||
public async Task BookmarkNoteAsync(Note note, User user)
|
||||
{
|
||||
if (user.Host != null) throw new Exception("This method is only valid for local users");
|
||||
|
||||
if (!await db.NoteBookmarks.AnyAsync(p => p.Note == note && p.User == user))
|
||||
{
|
||||
var bookmark = new NoteBookmark
|
||||
|
@ -790,13 +792,17 @@ public class NoteService(
|
|||
}
|
||||
}
|
||||
|
||||
public async Task UnbookmarkNoteAsync(Note note, User actor)
|
||||
public async Task UnbookmarkNoteAsync(Note note, User user)
|
||||
{
|
||||
await db.NoteBookmarks.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync();
|
||||
if (user.Host != null) throw new Exception("This method is only valid for local users");
|
||||
|
||||
await db.NoteBookmarks.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task PinNoteAsync(Note note, User user)
|
||||
{
|
||||
if (user.Host != null) throw new Exception("This method is only valid for local users");
|
||||
|
||||
if (note.User != user)
|
||||
throw GracefulException.UnprocessableEntity("Validation failed: Someone else's post cannot be pinned");
|
||||
|
||||
|
@ -812,12 +818,21 @@ public class NoteService(
|
|||
|
||||
await db.UserNotePins.AddAsync(pin);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user));
|
||||
await deliverSvc.DeliverToFollowersAsync(activity, user, []);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnpinNoteAsync(Note note, User actor)
|
||||
public async Task UnpinNoteAsync(Note note, User user)
|
||||
{
|
||||
await db.UserNotePins.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync();
|
||||
if (user.Host != null) throw new Exception("This method is only valid for local users");
|
||||
|
||||
var count = await db.UserNotePins.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync();
|
||||
if (count == 0) return;
|
||||
|
||||
var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user));
|
||||
await deliverSvc.DeliverToFollowersAsync(activity, user, []);
|
||||
}
|
||||
|
||||
public async Task UpdatePinnedNotesAsync(ASActor actor, User user)
|
||||
|
@ -827,9 +842,9 @@ public class NoteService(
|
|||
if (collection == null) return;
|
||||
if (collection.IsUnresolved)
|
||||
collection = await objectResolver.ResolveObject(collection, force: true) as ASOrderedCollection;
|
||||
if (collection is not { OrderedItems: not null }) return;
|
||||
if (collection is not { Items: not null }) return;
|
||||
|
||||
var items = await collection.OrderedItems.Take(10).Select(p => objectResolver.ResolveObject(p)).AwaitAllAsync();
|
||||
var items = await collection.Items.Take(10).Select(p => objectResolver.ResolveObject(p)).AwaitAllAsync();
|
||||
var notes = await items.OfType<ASNote>().Select(p => ResolveNoteAsync(p.Id, p)).AwaitAllNoConcurrencyAsync();
|
||||
var previousPins = await db.Users.Where(p => p.Id == user.Id)
|
||||
.Select(p => p.PinnedNotes.Select(i => i.Id))
|
||||
|
|
Loading…
Add table
Reference in a new issue