Post reactions

This commit is contained in:
LittleSheep 2025-05-05 00:58:28 +08:00
parent 5844dfb657
commit 1c361b94f3
5 changed files with 122 additions and 8 deletions

View File

@ -80,6 +80,7 @@ public class AppDatabase(
Nodes = Nodes =
{ {
PermissionService.NewPermissionNode("group:default", "global", "posts.create", true), PermissionService.NewPermissionNode("group:default", "global", "posts.create", true),
PermissionService.NewPermissionNode("group:default", "global", "posts.reactions.create", true),
PermissionService.NewPermissionNode("group:default", "global", "publishers.create", true), PermissionService.NewPermissionNode("group:default", "global", "publishers.create", true),
PermissionService.NewPermissionNode("group:default", "global", "files.create", true), PermissionService.NewPermissionNode("group:default", "global", "files.create", true),
PermissionService.NewPermissionNode("group:default", "global", "chat.create", true) PermissionService.NewPermissionNode("group:default", "global", "chat.create", true)

View File

@ -42,6 +42,7 @@ public class Post : ModelBase
public int ViewsTotal { get; set; } public int ViewsTotal { get; set; }
public int Upvotes { get; set; } public int Upvotes { get; set; }
public int Downvotes { get; set; } public int Downvotes { get; set; }
[NotMapped] public Dictionary<string, int> ReactionsCount { get; set; } = new();
public long? ThreadedPostId { get; set; } public long? ThreadedPostId { get; set; }
public Post? ThreadedPost { get; set; } public Post? ThreadedPost { get; set; }
@ -106,5 +107,6 @@ public class PostReaction : ModelBase
public long PostId { get; set; } public long PostId { get; set; }
[JsonIgnore] public Post Post { get; set; } = null!; [JsonIgnore] public Post Post { get; set; } = null!;
public long AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Account.Account Account { get; set; } = null!;
} }

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@ -36,6 +37,11 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
posts = PostService.TruncatePostContent(posts); posts = PostService.TruncatePostContent(posts);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
post.ReactionsCount = reactionMaps[post.Id];
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
@ -62,6 +68,8 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
post.ReactionsCount = await ps.GetPostReactionMap(post.Id);
return Ok(post); return Ok(post);
} }
@ -73,13 +81,13 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
var currentUser = currentUserValue as Account.Account; var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var post = await db.Posts var parent = await db.Posts
.Where(e => e.Id == id) .Where(e => e.Id == id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (parent is null) return NotFound();
var totalCount = await db.Posts var totalCount = await db.Posts
.Where(e => e.RepliedPostId == post.Id) .Where(e => e.RepliedPostId == parent.Id)
.FilterWithVisibility(currentUser, userFriends, isListing: true) .FilterWithVisibility(currentUser, userFriends, isListing: true)
.CountAsync(); .CountAsync();
var posts = await db.Posts var posts = await db.Posts
@ -96,6 +104,11 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
posts = PostService.TruncatePostContent(posts); posts = PostService.TruncatePostContent(posts);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
post.ReactionsCount = reactionMaps[post.Id];
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
@ -194,6 +207,45 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
return post; return post;
} }
public class PostReactionRequest
{
[MaxLength(256)] public string Symbol { get; set; } = null!;
public PostReactionAttitude Attitude { get; set; }
}
[HttpPost("{id:long}/reactions")]
[Authorize]
[RequiredPermission("global", "posts.reactions.create")]
public async Task<ActionResult<PostReaction>> CreatePostReaction(long id, [FromBody] PostReactionRequest request)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account.Account currentUser) return Unauthorized();
var userFriends = await rels.ListAccountFriends(currentUser);
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var isExistingReaction = await db.PostReactions
.AnyAsync(r => r.PostId == post.Id &&
r.Symbol == request.Symbol &&
r.AccountId == currentUser.Id);
var reaction = new PostReaction
{
Symbol = request.Symbol,
Attitude = request.Attitude,
PostId = post.Id,
AccountId = currentUser.Id
};
await ps.ModifyPostVotes(post, reaction, isExistingReaction);
if (isExistingReaction) return NoContent();
return Ok(reaction);
}
[HttpPatch("{id:long}")] [HttpPatch("{id:long}")]
public async Task<ActionResult<Post>> UpdatePost(long id, [FromBody] PostRequest request) public async Task<ActionResult<Post>> UpdatePost(long id, [FromBody] PostRequest request)
{ {

View File

@ -125,12 +125,12 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array }) if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array })
{ {
var searchTextBuilder = new System.Text.StringBuilder(); var searchTextBuilder = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(post.Title)) if (!string.IsNullOrWhiteSpace(post.Title))
searchTextBuilder.AppendLine(post.Title); searchTextBuilder.AppendLine(post.Title);
if (!string.IsNullOrWhiteSpace(post.Description)) if (!string.IsNullOrWhiteSpace(post.Description))
searchTextBuilder.AppendLine(post.Description); searchTextBuilder.AppendLine(post.Description);
foreach (var element in post.Content.RootElement.EnumerateArray()) foreach (var element in post.Content.RootElement.EnumerateArray())
{ {
if (element is { ValueKind: JsonValueKind.Object } && if (element is { ValueKind: JsonValueKind.Object } &&
@ -140,6 +140,7 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
searchTextBuilder.Append(insertProperty.GetString()); searchTextBuilder.Append(insertProperty.GetString());
} }
} }
post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim()); post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim());
} }
@ -225,12 +226,12 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array }) if (post.Content?.RootElement is { ValueKind: JsonValueKind.Array })
{ {
var searchTextBuilder = new System.Text.StringBuilder(); var searchTextBuilder = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(post.Title)) if (!string.IsNullOrWhiteSpace(post.Title))
searchTextBuilder.AppendLine(post.Title); searchTextBuilder.AppendLine(post.Title);
if (!string.IsNullOrWhiteSpace(post.Description)) if (!string.IsNullOrWhiteSpace(post.Description))
searchTextBuilder.AppendLine(post.Description); searchTextBuilder.AppendLine(post.Description);
foreach (var element in post.Content.RootElement.EnumerateArray()) foreach (var element in post.Content.RootElement.EnumerateArray())
{ {
if (element is { ValueKind: JsonValueKind.Object } && if (element is { ValueKind: JsonValueKind.Object } &&
@ -240,6 +241,7 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
searchTextBuilder.Append(insertProperty.GetString()); searchTextBuilder.Append(insertProperty.GetString());
} }
} }
post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim()); post.SearchVector = EF.Functions.ToTsVector(searchTextBuilder.ToString().Trim());
} }
@ -255,6 +257,62 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await fs.MarkUsageRangeAsync(post.Attachments, -1); await fs.MarkUsageRangeAsync(post.Attachments, -1);
} }
/// <summary>
/// Calculate the total number of votes for a post.
/// This function helps you save the new reactions.
/// </summary>
/// <param name="post">Post that modifying</param>
/// <param name="reaction">The new / target reaction adding / removing</param>
/// <param name="isRemoving">Indicate this operation is adding / removing</param>
public async Task ModifyPostVotes(Post post, PostReaction reaction, bool isRemoving)
{
var isExistingReaction = await db.Set<PostReaction>()
.AnyAsync(r => r.PostId == post.Id && r.AccountId == reaction.AccountId);
if (isExistingReaction) return;
if (!isRemoving)
{
db.Add(reaction);
switch (reaction.Attitude)
{
case PostReactionAttitude.Positive:
post.Upvotes++;
break;
case PostReactionAttitude.Negative:
post.Downvotes++;
break;
}
}
await db.SaveChangesAsync();
}
public async Task<Dictionary<string, int>> GetPostReactionMap(long postId)
{
return await db.Set<PostReaction>()
.Where(r => r.PostId == postId)
.GroupBy(r => r.Symbol)
.ToDictionaryAsync(
g => g.Key,
g => g.Count()
);
}
public async Task<Dictionary<long, Dictionary<string, int>>> GetPostReactionMapBatch(List<long> postIds)
{
return await db.Set<PostReaction>()
.Where(r => postIds.Contains(r.PostId))
.GroupBy(r => r.PostId)
.ToDictionaryAsync(
g => g.Key,
g => g.GroupBy(r => r.Symbol)
.ToDictionary(
sg => sg.Key,
sg => sg.Count()
)
);
}
} }
public static class PostQueryExtensions public static class PostQueryExtensions

View File

@ -6,6 +6,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003Ff6_003Fdf150bb3_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F051ad509d0504b7ca10dedd9c2cabb9914200_003F8e_003Fb28257cb_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F051ad509d0504b7ca10dedd9c2cabb9914200_003F8e_003Fb28257cb_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalTriggerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F5c_003F297b8312_003FDailyTimeIntervalTriggerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalTriggerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F5c_003F297b8312_003FDailyTimeIntervalTriggerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>