[backend/federation] Add reactions support (ISH-69)

This commit is contained in:
Laura Hausmann 2024-03-07 22:07:51 +01:00
parent c9be2f2e49
commit 52a7f90697
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
22 changed files with 6209 additions and 40 deletions

View file

@ -2,5 +2,6 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

View file

@ -4,6 +4,7 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -14,7 +15,8 @@ public class NoteRenderer(
UserRenderer userRenderer,
PollRenderer pollRenderer,
MfmConverter mfmConverter,
DatabaseContext db
DatabaseContext db,
EmojiService emojiSvc
)
{
public async Task<StatusEntity> RenderAsync(Note note, User? user, NoteRendererDto? data = null, int recurse = 2)
@ -53,6 +55,9 @@ public class NoteRenderer(
? await GetAttachments([note])
: [..data.Attachments.Where(p => note.FileIds.Contains(p.Id))];
var reactions = data?.Reactions == null
? await GetReactions([note], user)
: [..data.Reactions.Where(p => p.NoteId == note.Id)];
var mentionedUsers = mentions.Select(p => new Note.MentionedUser
{
@ -103,7 +108,8 @@ public class NoteRenderer(
IsPinned = pinned,
Attachments = attachments,
Emojis = noteEmoji,
Poll = poll
Poll = poll,
Reactions = reactions
};
return res;
@ -149,6 +155,37 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<ReactionEntity>> GetReactions(List<Note> notes, User? user)
{
if (user == null) return [];
var counts = notes.ToDictionary(p => p.Id, p => p.Reactions);
var res = await db.NoteReactions
.Where(p => notes.Contains(p.Note))
.GroupBy(p => p.Reaction)
.Select(p => new ReactionEntity
{
NoteId = p.First().NoteId,
Count = (int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
Me = db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
i.Reaction == p.First().Reaction &&
i.User == user),
Name = p.First().Reaction,
Url = null,
StaticUrl = null
})
.ToListAsync();
foreach (var item in res.Where(item => item.Name.StartsWith(':')))
{
var hit = await emojiSvc.ResolveEmoji(item.Name);
if (hit == null) continue;
item.Url = hit.PublicUrl;
item.StaticUrl = hit.PublicUrl;
}
return res;
}
private async Task<List<string>> GetBookmarkedNotes(IEnumerable<Note> notes, User? user)
{
if (user == null) return [];
@ -222,7 +259,8 @@ public class NoteRenderer(
BookmarkedNotes = await GetBookmarkedNotes(noteList, user),
PinnedNotes = await GetPinnedNotes(noteList, user),
Renotes = await GetRenotes(noteList, user),
Emoji = await GetEmoji(noteList)
Emoji = await GetEmoji(noteList),
Reactions = await GetReactions(noteList, user)
};
return await noteList.Select(p => RenderAsync(p, user, data)).AwaitAllAsync();
@ -239,6 +277,8 @@ public class NoteRenderer(
public List<string>? PinnedNotes;
public List<string>? Renotes;
public List<EmojiEntity>? Emoji;
public bool Source;
public List<ReactionEntity>? Reactions;
public bool Source;
}
}

View file

@ -0,0 +1,17 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class ReactionEntity
{
[JI] public required string NoteId;
[J("count")] public required int Count { get; set; }
[J("me")] public required bool Me { get; set; }
[J("name")] public required string Name { get; set; }
[J("url")] public required string? Url { get; set; }
[J("static_url")] public required string? StaticUrl { get; set; }
[J("accounts")] public List<AccountEntity>? Accounts { get; set; }
[J("account_ids")] public List<string>? AccountIds { get; set; }
}

View file

@ -9,6 +9,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class StatusEntity : IEntity
{
[J("id")] public required string Id { get; set; }
[J("content")] public required string? Content { get; set; }
[J("uri")] public required string Uri { get; set; }
[J("url")] public required string Url { get; set; }
@ -38,23 +39,21 @@ public class StatusEntity : IEntity
[J("pinned")]
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required bool? IsPinned { get; set; }
[J("poll")] public required PollEntity? Poll { get; set; }
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
[J("reactions")] public required List<ReactionEntity> Reactions { get; set; }
[J("tags")] public object[] Tags => []; //FIXME
[J("reactions")] public object[] Reactions => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("tags")] public object[] Tags => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("language")] public string? Language => null; //FIXME
[J("id")] public required string Id { get; set; }
public static string EncodeVisibility(Note.NoteVisibility visibility)
{
return visibility switch

View file

@ -122,6 +122,46 @@ public class StatusController(
return await GetNote(id);
}
[HttpPost("{id}/react/{reaction}")]
[Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> ReactNote(string id, string reaction)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id)
.IncludeCommonProperties()
.EnsureVisibleFor(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var res = await noteSvc.ReactToNoteAsync(note, user, reaction);
if (res != null && !note.Reactions.TryAdd(res, 1))
note.Reactions[res]++; // we do not want to call save changes after this point
return await GetNote(id);
}
[HttpPost("{id}/unreact/{reaction}")]
[Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UnreactNote(string id, string reaction)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id)
.IncludeCommonProperties()
.EnsureVisibleFor(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, reaction);
if (res != null && note.Reactions.TryGetValue(res, out var value))
note.Reactions[res] = --value; // we do not want to call save changes after this point
return await GetNote(id);
}
[HttpPost("{id}/bookmark")]
[Authorize("write:bookmarks")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AllowMultipleReactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_note_reaction_userId_noteId",
table: "note_reaction");
migrationBuilder.CreateIndex(
name: "IX_note_reaction_userId_noteId_reaction",
table: "note_reaction",
columns: new[] { "userId", "noteId", "reaction" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_note_reaction_userId_noteId_reaction",
table: "note_reaction");
migrationBuilder.CreateIndex(
name: "IX_note_reaction_userId_noteId",
table: "note_reaction",
columns: new[] { "userId", "noteId" },
unique: true);
}
}
}

View file

@ -2547,7 +2547,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.HasIndex("UserId");
b.HasIndex("UserId", "NoteId")
b.HasIndex("UserId", "NoteId", "Reaction")
.IsUnique();
b.ToTable("note_reaction");

View file

@ -8,7 +8,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index("CreatedAt")]
[Index("UserId")]
[Index("NoteId")]
[Index("UserId", "NoteId", IsUnique = true)]
[Index("UserId", "NoteId", "Reaction", IsUnique = true)]
public class NoteReaction
{
[Key]

View file

@ -1,13 +1,19 @@
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
public class ActivityDeliverService(ILogger<ActivityDeliverService> logger, QueueService queueService)
public class ActivityDeliverService(
ILogger<ActivityDeliverService> logger,
QueueService queueService,
DatabaseContext db
)
{
public async Task DeliverToFollowersAsync(ASActivity activity, User actor, IEnumerable<User> recipients)
{
@ -40,4 +46,18 @@ public class ActivityDeliverService(ILogger<ActivityDeliverService> logger, Queu
DeliverToFollowers = false
});
}
public async Task DeliverToConditionalAsync(ASActivity activity, User actor, Note note)
{
if (note.Visibility != Note.NoteVisibility.Specified)
{
await DeliverToFollowersAsync(activity, actor, [note.User]);
return;
}
await DeliverToAsync(activity, actor, await db.Users.Where(p => note.VisibleUserIds
.Prepend(note.User.Id)
.Contains(p.Id))
.ToArrayAsync());
}
}

View file

@ -30,7 +30,8 @@ public class ActivityHandlerService(
NotificationService notificationSvc,
ActivityDeliverService deliverSvc,
ObjectResolver objectResolver,
FollowupTaskService followupTaskSvc
FollowupTaskService followupTaskSvc,
EmojiService emojiSvc
)
{
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authFetchUserId)
@ -149,6 +150,9 @@ public class ActivityHandlerService(
case ASAnnounce { Object: ASNote likedNote }:
await noteSvc.UndoAnnounceAsync(likedNote, resolvedActor);
return;
case ASEmojiReact { Object: ASNote note } react:
await noteSvc.RemoveReactionFromNoteAsync(note, resolvedActor, react.Content);
return;
default:
throw GracefulException.UnprocessableEntity("Undo activity object is invalid");
}
@ -246,6 +250,14 @@ public class ActivityHandlerService(
await noteSvc.CreateNoteAsync(resolvedActor, announce.GetVisibility(activity.Actor), renote: dbNote);
return;
}
case ASEmojiReact reaction:
{
if (reaction.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Invalid or unsupported reaction target");
await emojiSvc.ProcessEmojiAsync(reaction.Tags?.OfType<ASEmoji>().ToList(), resolvedActor.Host);
await noteSvc.ReactToNoteAsync(note, resolvedActor, reaction.Content);
return;
}
default:
throw new NotImplementedException($"Activity type {activity.Type} is unknown");
}

View file

@ -59,19 +59,49 @@ public class ActivityRenderer(
Id = GenerateActivityId(), Actor = actor.Compact(), Object = obj
};
public ASLike RenderLike(Note note, User user)
public ASLike RenderLike(NoteLike like)
{
if (note.UserHost == null)
if (like.Note.UserHost == null)
throw GracefulException.BadRequest("Refusing to render like activity: note user must be remote");
if (user.Host != null)
if (like.User.Host != null)
throw GracefulException.BadRequest("Refusing to render like activity: actor must be local");
return new ASLike
{
Id = GenerateActivityId(), Actor = userRenderer.RenderLite(user), Object = noteRenderer.RenderLite(note)
Id = $"https://{config.Value.WebDomain}/likes/${like.Id}",
Actor = userRenderer.RenderLite(like.User),
Object = noteRenderer.RenderLite(like.Note)
};
}
public ASEmojiReact RenderReact(NoteReaction reaction, Emoji? emoji)
{
if (reaction.Note.UserHost == null)
throw GracefulException.BadRequest("Refusing to render like activity: note user must be remote");
if (reaction.User.Host != null)
throw GracefulException.BadRequest("Refusing to render like activity: actor must be local");
var res = new ASEmojiReact
{
Id = $"https://{config.Value.WebDomain}/reactions/{reaction.Id}",
Actor = userRenderer.RenderLite(reaction.User),
Object = noteRenderer.RenderLite(reaction.Note),
Content = reaction.Reaction
};
if (emoji != null)
{
var e = new ASEmoji
{
Id = emoji.PublicUrl, Name = emoji.Name, Image = new ASImage { Url = new ASLink(emoji.PublicUrl) }
};
res.Tags = [e];
}
return res;
}
public ASFollow RenderFollow(User follower, User followee)
{
if (follower.Host == null && followee.Host == null)
@ -162,7 +192,7 @@ public class ActivityRenderer(
return RenderAnnounce(note, actor, to, cc, renoteUri);
}
public ASNote RenderVote(PollVote vote, Poll poll, Note note) => new()
{
Id = GenerateActivityId(),

View file

@ -45,6 +45,10 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
})
.ToListAsync();
var emoji = note.Emojis.Count != 0
? await db.Emojis.Where(p => note.Emojis.Contains(p.Id) && p.Host == null).ToListAsync()
: [];
var to = note.Visibility switch
{
Note.NoteVisibility.Public => [new ASLink($"{Constants.ActivityStreamsNs}#Public")],
@ -69,9 +73,14 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
Name = $"@{mention.Username}@{mention.Host}",
Href = new ASObjectBase(mention.Uri)
}))
.Concat(emoji.Select(e => new ASEmoji
{
Id = $"https://{config.Value.WebDomain}/emoji/{e.Name}",
Name = e.Name,
Image = new ASImage { Url = new ASLink(e.PublicUrl) }
}))
.ToList();
var attachments = note.FileIds.Count > 0
? await db.DriveFiles
.Where(p => note.FileIds.Contains(p.Id) && p.UserHost == null)

View file

@ -7,6 +7,7 @@
"featured": {
"@id": "http://joinmastodon.org/ns#featured",
"@type": "@id"
}
},
"EmojiReact": "http://litepub.social/ns#EmojiReact"
}
}

View file

@ -33,7 +33,8 @@
"isCat": "misskey:isCat",
"fedibird": "http://fedibird.com/ns#",
"vcard": "http://www.w3.org/2006/vcard/ns#",
"Bite": "https://ns.mia.jetzt/as#Bite"
"Bite": "https://ns.mia.jetzt/as#Bite",
"EmojiReact": "http://litepub.social/ns#EmojiReact"
}
]
}

View file

@ -2,6 +2,7 @@ using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
using JR = Newtonsoft.Json.JsonRequiredAttribute;
using JI = Newtonsoft.Json.JsonIgnoreAttribute;
using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter;
@ -34,7 +35,8 @@ public class ASActivity : ASObject
public const string Like = $"{Ns}#Like";
// Extensions
public const string Bite = "https://ns.mia.jetzt/as#Bite";
public const string Bite = "https://ns.mia.jetzt/as#Bite";
public const string EmojiReact = $"http://litepub.social/ns#EmojiReact";
}
}
@ -148,6 +150,7 @@ public class ASBite : ASActivity
{
public ASBite() => Type = Types.Bite;
[JR]
[J($"{Constants.ActivityStreamsNs}#target")]
[JC(typeof(ASObjectBaseConverter))]
public required ASObjectBase Target { get; set; }
@ -159,4 +162,18 @@ public class ASBite : ASActivity
[J($"{Constants.ActivityStreamsNs}#published")]
[JC(typeof(VC))]
public DateTime? PublishedAt { get; set; }
}
public class ASEmojiReact : ASActivity
{
public ASEmojiReact() => Type = Types.EmojiReact;
[JR]
[J($"{Constants.ActivityStreamsNs}#content")]
[JC(typeof(VC))]
public required string Content { get; set; }
[J($"{Constants.ActivityStreamsNs}#tag")]
[JC(typeof(ASTagConverter))]
public List<ASTag>? Tags { get; set; }
}

View file

@ -7,6 +7,10 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASImage
{
[J("@type")]
[JC(typeof(StringListSingleConverter))]
public string Type => $"{Constants.ActivityStreamsNs}#Image";
[J($"{Constants.ActivityStreamsNs}#url")]
[JC(typeof(ASLinkConverter))]
public ASLink? Url { get; set; }

View file

@ -58,6 +58,7 @@ public class ASObject : ASObjectBase
ASActivity.Types.Like => token.ToObject<ASLike>(),
ASActivity.Types.Bite => token.ToObject<ASBite>(),
ASActivity.Types.Announce => token.ToObject<ASAnnounce>(),
ASActivity.Types.EmojiReact => token.ToObject<ASEmojiReact>(),
_ => token.ToObject<ASObject>()
};
case JTokenType.Array:

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,8 @@ public class EventService
public event EventHandler<Note>? NoteDeleted;
public event EventHandler<NoteInteraction>? NoteLiked;
public event EventHandler<NoteInteraction>? NoteUnliked;
public event EventHandler<NoteReaction>? NoteReacted;
public event EventHandler<NoteReaction>? NoteUnreacted;
public event EventHandler<Notification>? Notification;
public void RaiseNotePublished(object? sender, Note note) => NotePublished?.Invoke(sender, note);
@ -29,4 +31,10 @@ public class EventService
public void RaiseNoteUnliked(object? sender, Note note, User user) =>
NoteUnliked?.Invoke(sender, new NoteInteraction { Note = note, User = user });
public void RaiseNoteReacted(object? sender, NoteReaction reaction) =>
NoteReacted?.Invoke(sender, reaction);
public void RaiseNoteUnreacted(object? sender, NoteReaction reaction) =>
NoteUnreacted?.Invoke(sender, reaction);
}

View file

@ -288,7 +288,8 @@ public class NoteService(
db.Update(note.Poll);
}
}
else {
else
{
poll.Note = note;
poll.UserId = note.User.Id;
poll.UserHost = note.UserHost;
@ -976,8 +977,8 @@ public class NoteService(
if (user.Host == null && note.UserHost != null)
{
var activity = activityRenderer.RenderLike(note, user);
await deliverSvc.DeliverToFollowersAsync(activity, user, [note.User]);
var activity = activityRenderer.RenderLike(like);
await deliverSvc.DeliverToConditionalAsync(activity, user, note);
}
eventSvc.RaiseNoteLiked(this, note, user);
@ -988,26 +989,28 @@ public class NoteService(
return false;
}
public async Task<bool> UnlikeNoteAsync(Note note, User actor)
public async Task<bool> UnlikeNoteAsync(Note note, User user)
{
var count = await db.NoteLikes.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync();
if (count == 0) return false;
var like = await db.NoteLikes.Where(p => p.Note == note && p.User == user).FirstOrDefaultAsync();
if (like == null) return false;
db.Remove(like);
await db.SaveChangesAsync();
await db.Notes.Where(p => p.Id == note.Id)
.ExecuteUpdateAsync(p => p.SetProperty(n => n.LikeCount, n => n.LikeCount - count));
.ExecuteUpdateAsync(p => p.SetProperty(n => n.LikeCount, n => n.LikeCount - 1));
if (actor.Host == null && note.UserHost != null)
if (user.Host == null && note.UserHost != null)
{
var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(actor),
activityRenderer.RenderLike(note, actor));
await deliverSvc.DeliverToFollowersAsync(activity, actor, [note.User]);
var activity =
activityRenderer.RenderUndo(userRenderer.RenderLite(user), activityRenderer.RenderLike(like));
await deliverSvc.DeliverToConditionalAsync(activity, user, note);
}
eventSvc.RaiseNoteUnliked(this, note, actor);
eventSvc.RaiseNoteUnliked(this, note, user);
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.Like &&
p.Notifiee == note.User &&
p.Notifier == actor)
p.Notifier == user)
.ExecuteDeleteAsync();
return true;
@ -1019,10 +1022,10 @@ public class NoteService(
await LikeNoteAsync(dbNote, actor);
}
public async Task UnlikeNoteAsync(ASNote note, User actor)
public async Task UnlikeNoteAsync(ASNote note, User user)
{
var dbNote = await ResolveNoteAsync(note) ?? throw new Exception("Cannot unregister like for unknown note");
await UnlikeNoteAsync(dbNote, actor);
await UnlikeNoteAsync(dbNote, user);
}
public async Task BookmarkNoteAsync(Note note, User user)
@ -1122,4 +1125,88 @@ public class NoteService(
var job = new PollExpiryJob { NoteId = poll.Note.Id };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, poll.ExpiresAt.Value);
}
public async Task<string?> ReactToNoteAsync(Note note, User user, string name)
{
name = await emojiSvc.ResolveEmojiName(name, user.Host);
if (await db.NoteReactions.AnyAsync(p => p.Note == note && p.User == user && p.Reaction == name))
return null;
var reaction = new NoteReaction
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Note = note,
User = user,
Reaction = name
};
await db.AddAsync(reaction);
await db.SaveChangesAsync();
eventSvc.RaiseNoteReacted(this, reaction);
await notificationSvc.GenerateReactionNotification(reaction);
await db.Database
.ExecuteSqlAsync($"""UPDATE "note" SET "reactions" = jsonb_set("reactions", ARRAY[{name}], (COALESCE("reactions"->>{name}, '0')::int + 1)::text::jsonb) WHERE "id" = {note.Id}""");
if (user.Host == null && note.User.Host != null)
{
var emoji = await emojiSvc.ResolveEmoji(reaction.Reaction);
var activity = activityRenderer.RenderReact(reaction, emoji);
await deliverSvc.DeliverToConditionalAsync(activity, user, note);
}
return name;
}
public async Task ReactToNoteAsync(ASNote note, User actor, string name)
{
var dbNote = await ResolveNoteAsync(note.Id, note.VerifiedFetch ? note : null);
if (dbNote == null)
throw GracefulException.UnprocessableEntity("Failed to resolve reaction target");
await ReactToNoteAsync(dbNote, actor, name);
}
public async Task<string?> RemoveReactionFromNoteAsync(Note note, User user, string name)
{
name = await emojiSvc.ResolveEmojiName(name, user.Host);
var reaction =
await db.NoteReactions.FirstOrDefaultAsync(p => p.Note == note && p.User == user && p.Reaction == name);
if (reaction == null) return null;
db.Remove(reaction);
await db.SaveChangesAsync();
eventSvc.RaiseNoteUnreacted(this, reaction);
await db.Database
.ExecuteSqlAsync($"""UPDATE "note" SET "reactions" = jsonb_set("reactions", ARRAY[{name}], (COALESCE("reactions"->>{name}, '1')::int - 1)::text::jsonb) WHERE "id" = {note.Id}""");
if (user.Host == null && note.User.Host != null)
{
var actor = userRenderer.RenderLite(user);
var emoji = await emojiSvc.ResolveEmoji(reaction.Reaction);
var activity = activityRenderer.RenderUndo(actor, activityRenderer.RenderReact(reaction, emoji));
await deliverSvc.DeliverToConditionalAsync(activity, user, note);
}
if (note.User.Host == null && note.User != user)
{
await db.Notifications
.Where(p => p.Note == note &&
p.Notifier == user &&
p.Type == Notification.NotificationType.Reaction)
.ExecuteDeleteAsync();
}
return name;
}
public async Task RemoveReactionFromNoteAsync(ASNote note, User actor, string name)
{
var dbNote = await ResolveNoteAsync(note.Id, note.VerifiedFetch ? note : null);
if (dbNote == null) return;
await RemoveReactionFromNoteAsync(dbNote, actor, name);
}
}

View file

@ -104,6 +104,26 @@ public class NotificationService(
await db.SaveChangesAsync();
eventSvc.RaiseNotification(this, notification);
}
public async Task GenerateReactionNotification(NoteReaction reaction)
{
if (reaction.Note.User.Host != null) return;
if (reaction.Note.User == reaction.User) return;
var notification = new Notification
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Note = reaction.Note,
Notifiee = reaction.Note.User,
Notifier = reaction.User,
Type = Notification.NotificationType.Reaction
};
await db.AddAsync(notification);
await db.SaveChangesAsync();
eventSvc.RaiseNotification(this, notification);
}
public async Task GenerateFollowNotification(User follower, User followee)
{