[backend/federation] Resolve user pinned posts (ISH-67)

This commit is contained in:
Laura Hausmann 2024-03-01 00:40:46 +01:00
parent e69bbcb977
commit 81059291c3
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
9 changed files with 104 additions and 21 deletions

View file

@ -14,15 +14,15 @@ public class ObjectResolver(
IOptions<Config.InstanceSection> config
)
{
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj, int recurse = 5)
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj, int recurse = 5, bool force = false)
{
if (baseObj is ASActivity { Object.IsUnresolved: true } activity && recurse > 0)
{
activity.Object = await ResolveObject(activity.Object, --recurse);
activity.Object = await ResolveObject(activity.Object, --recurse, force);
return await ResolveObject(activity, recurse);
}
if (baseObj is ASObject { IsUnresolved: false } obj)
if (baseObj is ASObject { IsUnresolved: false } obj && !force)
return obj;
if (baseObj.Id == null)
{

View file

@ -52,8 +52,8 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Type = type,
Inbox = new ASLink($"{id}/inbox"),
Outbox = new ASCollection($"{id}/outbox"),
Followers = new ASCollection($"{id}/followers"),
Following = new ASCollection($"{id}/following"),
Followers = new ASOrderedCollection($"{id}/followers"),
Following = new ASOrderedCollection($"{id}/following"),
SharedInbox = new ASLink($"https://{config.Value.WebDomain}/inbox"),
Url = new ASLink(user.GetPublicUrl(config.Value)),
Username = user.Username,

View file

@ -3,6 +3,7 @@
"movedTo": {
"@id": "https://www.w3.org/ns/activitystreams#movedTo",
"@type": "@id"
}
},
"featured": "http://joinmastodon.org/ns#featured"
}
}

View file

@ -75,12 +75,12 @@ public class ASActor : ASObject
public ASLink? Inbox { get; set; }
[J($"{Constants.ActivityStreamsNs}#followers")]
[JC(typeof(ASCollectionConverter))]
public ASCollection? Followers { get; set; }
[JC(typeof(ASOrderedCollectionConverter))]
public ASOrderedCollection? Followers { get; set; }
[J($"{Constants.ActivityStreamsNs}#following")]
[JC(typeof(ASCollectionConverter))]
public ASCollection? Following { get; set; }
[JC(typeof(ASOrderedCollectionConverter))]
public ASOrderedCollection? Following { get; set; }
[J($"{Constants.ActivityStreamsNs}#sharedInbox")]
[JC(typeof(ASLinkConverter))]
@ -98,8 +98,8 @@ public class ASActor : ASObject
public List<ASLink>? AlsoKnownAs { get; set; }
[J("http://joinmastodon.org/ns#featured")]
[JC(typeof(ASLinkConverter))]
public ASLink? Featured { get; set; }
[JC(typeof(VC))]
public ASOrderedCollection? Featured { get; set; }
[J("http://joinmastodon.org/ns#featuredTags")]
[JC(typeof(ASLinkConverter))]

View file

@ -35,6 +35,8 @@ public class ASCollection : ASObject
[JC(typeof(ASLinkConverter))]
public ASLink? Last { get; set; }
public new bool IsUnresolved => !TotalItems.HasValue;
public const string ObjectType = $"{Constants.ActivityStreamsNs}#Collection";
}
@ -59,7 +61,7 @@ internal sealed class ASCollectionItemsConverter : JsonConverter
var obj = JArray.Load(reader);
return obj.Count == 0
? null
: obj.SelectToken("..@list")?.Children().Select(ASObject.Deserialize).OfType<ASObject>().ToList();
: obj.SelectToken("$.[*].@list")?.Children().Select(ASObject.Deserialize).OfType<ASObject>().ToList();
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)

View file

@ -1,18 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Configuration;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
using JI = Newtonsoft.Json.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASOrderedCollection : ASCollection
{
public ASOrderedCollection() => Type = ObjectType;
[SetsRequiredMembers]
public ASOrderedCollection(string id) : this() => Id = id;
[J($"{Constants.ActivityStreamsNs}#orderedItems")]
public List<ASObject>? OrderedItems { get; set; }
[JC(typeof(ASCollectionItemsConverter))]
[JI]
public List<ASObject>? OrderedItems
{
get => Items;
set => Items = value;
}
public new const string ObjectType = $"{Constants.ActivityStreamsNs}#OrderedCollection";
}
}
public sealed class ASOrderedCollectionConverter : ASSerializer.ListSingleObjectConverter<ASOrderedCollection>;

View file

@ -65,6 +65,14 @@ public class ValueObjectConverter : JsonConverter
return val != null ? Convert.ToUInt64(val) : null;
}
if (obj?.Value is string id)
{
if (objectType == typeof(ASOrderedCollection))
return new ASOrderedCollection(id);
if (objectType == typeof(ASCollection))
return new ASCollection(id);
}
return obj?.Value;
}
@ -92,6 +100,18 @@ public class ValueObjectConverter : JsonConverter
writer.WritePropertyName("@value");
writer.WriteValue(ul);
break;
case ASOrderedCollection oc:
writer.WritePropertyName("@type");
writer.WriteValue(ASOrderedCollection.ObjectType);
writer.WritePropertyName("@value");
writer.WriteRawValue(JsonConvert.SerializeObject(oc));
break;
case ASCollection c:
writer.WritePropertyName("@type");
writer.WriteValue(ASCollection.ObjectType);
writer.WritePropertyName("@value");
writer.WriteRawValue(JsonConvert.SerializeObject(c));
break;
default:
writer.WritePropertyName("@value");
writer.WriteValue(value);

View file

@ -38,7 +38,8 @@ public class NoteService(
EventService eventSvc,
ActivityPub.ActivityRenderer activityRenderer,
EmojiService emojiSvc,
FollowupTaskService followupTaskSvc
FollowupTaskService followupTaskSvc,
ActivityPub.ObjectResolver objectResolver
)
{
private readonly List<string> _resolverHistory = [];
@ -767,4 +768,37 @@ public class NoteService(
var dbNote = await ResolveNoteAsync(note) ?? throw new Exception("Cannot unregister like for unknown note");
await UnlikeNoteAsync(dbNote, actor);
}
public async Task UpdatePinnedNotesAsync(ASActor actor, User user)
{
logger.LogDebug("Updating pinned notes for user {user}", user.Id);
var collection = actor.Featured;
if (collection == null) return;
if (collection.IsUnresolved)
collection = await objectResolver.ResolveObject(collection, force: true) as ASOrderedCollection;
if (collection is not { OrderedItems: not null }) return;
var items = await collection.OrderedItems.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))
.FirstOrDefaultAsync() ??
throw new Exception("existingPins must not be null at this stage");
if (previousPins.SequenceEqual(notes.Where(p => p != null).Cast<Note>().Select(p => p.Id))) return;
var pins = notes.Where(p => p != null)
.Cast<Note>()
.Select(p => new UserNotePin
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Note = p,
User = user
});
db.RemoveRange(await db.UserNotePins.Where(p => p.User == user).ToListAsync());
await db.AddRangeAsync(pins);
await db.SaveChangesAsync();
}
}

View file

@ -153,7 +153,7 @@ public class UserService(
FollowersUri = actor.Followers?.Id,
Uri = actor.Id,
IsCat = actor.IsCat ?? false,
Featured = actor.Featured?.Link,
Featured = actor.Featured?.Id,
//TODO: FollowersCount
//TODO: FollowingCount
Emojis = emoji.Select(p => p.Id).ToList(),
@ -185,6 +185,7 @@ public class UserService(
await db.SaveChangesAsync();
await processPendingDeletes();
user = await UpdateProfileMentions(user, actor);
UpdateUserPinnedNotesInBackground(actor, user);
_ = followupTaskSvc.ExecuteTask("UpdateInstanceUserCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
@ -256,7 +257,7 @@ public class UserService(
user.IsExplorable = actor.IsDiscoverable ?? false;
user.FollowersUri = actor.Followers?.Id;
user.IsCat = actor.IsCat ?? false;
user.Featured = actor.Featured?.Link;
user.Featured = actor.Featured?.Id;
var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(),
user.Host ??
@ -301,6 +302,7 @@ public class UserService(
await db.SaveChangesAsync();
await processPendingDeletes();
user = await UpdateProfileMentions(user, actor, force: true);
UpdateUserPinnedNotesInBackground(actor, user, force: true);
return user;
}
@ -702,6 +704,20 @@ public class UserService(
// Clean up user list memberships
await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
}
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
private void UpdateUserPinnedNotesInBackground(ASActor actor, User user, bool force = false)
{
if (followupTaskSvc.IsBackgroundWorker && !force) return;
_ = followupTaskSvc.ExecuteTask("UpdateUserPinnedNotes", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgNoteSvc = provider.GetRequiredService<NoteService>();
var bgUser = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
if (bgUser == null) return;
await bgNoteSvc.UpdatePinnedNotesAsync(actor, bgUser);
});
}
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]