[backend/federation] Add reactions support (ISH-69)
This commit is contained in:
parent
c9be2f2e49
commit
52a7f90697
22 changed files with 6209 additions and 40 deletions
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))]
|
||||
|
|
5779
Iceshrimp.Backend/Core/Database/Migrations/20240307203857_AllowMultipleReactions.Designer.cs
generated
Normal file
5779
Iceshrimp.Backend/Core/Database/Migrations/20240307203857_AllowMultipleReactions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"featured": {
|
||||
"@id": "http://joinmastodon.org/ns#featured",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
"EmojiReact": "http://litepub.social/ns#EmojiReact"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -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
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
Loading…
Add table
Reference in a new issue