Compare commits

..

3 Commits

Author SHA1 Message Date
LittleSheep
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
LittleSheep
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
LittleSheep
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
8 changed files with 2257 additions and 40 deletions

View File

@@ -10,6 +10,8 @@ public abstract class ActionLogType
public const string PostUpdate = "posts.update"; public const string PostUpdate = "posts.update";
public const string PostDelete = "posts.delete"; public const string PostDelete = "posts.delete";
public const string PostReact = "posts.react"; public const string PostReact = "posts.react";
public const string PostPin = "posts.pin";
public const string PostUnpin = "posts.unpin";
public const string MessageCreate = "messages.create"; public const string MessageCreate = "messages.create";
public const string MessageUpdate = "messages.update"; public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete"; public const string MessageDelete = "messages.delete";
@@ -37,4 +39,4 @@ public abstract class ActionLogType
public const string ChatroomLeave = "chatrooms.leave"; public const string ChatroomLeave = "chatrooms.leave";
public const string ChatroomKick = "chatrooms.kick"; public const string ChatroomKick = "chatrooms.kick";
public const string ChatroomAdjustRole = "chatrooms.role.edit"; public const string ChatroomAdjustRole = "chatrooms.role.edit";
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPostPin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "pin_mode",
table: "posts",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pin_mode",
table: "posts");
}
}
}

View File

@@ -566,6 +566,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("meta"); .HasColumnName("meta");
b.Property<int?>("PinMode")
.HasColumnType("integer")
.HasColumnName("pin_mode");
b.Property<Instant?>("PublishedAt") b.Property<Instant?>("PublishedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("published_at"); .HasColumnName("published_at");

View File

@@ -23,6 +23,13 @@ public enum PostVisibility
Private Private
} }
public enum PostPinMode
{
PublisherPage,
RealmPage,
ReplyPage,
}
public class Post : ModelBase, IIdentifiedResource, IActivity public class Post : ModelBase, IIdentifiedResource, IActivity
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -37,6 +44,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
public string? Content { get; set; } public string? Content { get; set; }
public PostType Type { get; set; } public PostType Type { get; set; }
public PostPinMode? PinMode { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = []; [Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
@@ -97,7 +105,7 @@ public class PostTag : ModelBase
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>(); [JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }
@@ -107,7 +115,7 @@ public class PostCategory : ModelBase
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>(); [JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }

View File

@@ -81,7 +81,8 @@ public class PostController(
[FromQuery(Name = "query")] string? queryTerm = null, [FromQuery(Name = "query")] string? queryTerm = null,
[FromQuery(Name = "vector")] bool queryVector = false, [FromQuery(Name = "vector")] bool queryVector = false,
[FromQuery(Name = "media")] bool onlyMedia = false, [FromQuery(Name = "media")] bool onlyMedia = false,
[FromQuery(Name = "shuffle")] bool shuffle = false [FromQuery(Name = "shuffle")] bool shuffle = false,
[FromQuery(Name = "pinned")] bool pinned = false
) )
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
@@ -91,7 +92,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -116,7 +117,14 @@ public class PostController(
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug))); query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
if (onlyMedia) if (onlyMedia)
query = query.Where(e => e.Attachments.Count > 0); query = query.Where(e => e.Attachments.Count > 0);
if (pinned)
{
if (realm != null) query = query.Where(p => p.PinMode == PostPinMode.RealmPage);
else if (publisher != null) query = query.Where(p => p.PinMode == PostPinMode.PublisherPage);
else return BadRequest("You need pass extra realm or publisher params in order to filter with pinned posts.");
}
query = includeReplies switch query = includeReplies switch
{ {
false => query.Where(e => e.RepliedPostId == null), false => query.Where(e => e.RepliedPostId == null),
@@ -169,7 +177,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -188,9 +196,6 @@ public class PostController(
if (post is null) return NotFound(); if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser); post = await ps.LoadPostInfo(post, currentUser);
// Track view - use the account ID as viewer ID if user is logged in
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
return Ok(post); return Ok(post);
} }
@@ -203,7 +208,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -222,9 +227,6 @@ public class PostController(
if (post is null) return NotFound(); if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser); post = await ps.LoadPostInfo(post, currentUser);
// Track view - use the account ID as viewer ID if user is logged in
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
return Ok(post); return Ok(post);
} }
@@ -261,7 +263,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -278,12 +280,36 @@ public class PostController(
.FilterWithVisibility(currentUser, userFriends, userPublishers) .FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser); post = await ps.LoadPostInfo(post, currentUser, true);
// Track view - use the account ID as viewer ID if user is logged in return Ok(post);
await ps.IncreaseViewCount(post.Id, currentUser?.Id); }
return await ps.LoadPostInfo(post); [HttpGet("{id:guid}/replies/pinned")]
public async Task<ActionResult<List<Post>>> ListPinnedReplies(Guid id)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
List<Guid> userFriends = [];
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var now = SystemClock.Instance.GetCurrentInstant();
var posts = await db.Posts
.Where(e => e.RepliedPostId == id && e.PinMode == PostPinMode.ReplyPage)
.OrderByDescending(p => p.CreatedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.ToListAsync();
if (posts is null) return NotFound();
posts = await ps.LoadPostInfo(posts, currentUser);
return Ok(posts);
} }
[HttpGet("{id:guid}/replies")] [HttpGet("{id:guid}/replies")]
@@ -297,7 +323,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -484,7 +510,7 @@ public class PostController(
var friendsResponse = var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() }); { AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -535,6 +561,88 @@ public class PostController(
return Ok(reaction); return Ok(reaction);
} }
public class PostPinRequest
{
[Required] public PostPinMode Mode { get; set; }
}
[HttpPost("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<Post>> PinPost(Guid id, [FromBody] PostPinRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
try
{
await ps.PinPostAsync(post, currentUser, request.Mode);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
var accountId = Guid.Parse(currentUser.Id);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = ActionLogType.PostPin,
Meta =
{
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) },
{ "mode", Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString()) }
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(post);
}
[HttpDelete("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<Post>> UnpinPost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
try
{
await ps.UnpinPostAsync(post, currentUser);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = ActionLogType.PostUnpin,
Meta =
{
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) }
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(post);
}
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<ActionResult<Post>> UpdatePost( public async Task<ActionResult<Post>> UpdatePost(
Guid id, Guid id,
@@ -679,4 +787,4 @@ public class PostController(
return NoContent(); return NoContent();
} }
} }

View File

@@ -25,6 +25,7 @@ public partial class PostService(
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
PollService polls, PollService polls,
Publisher.PublisherService ps,
WebReaderService reader WebReaderService reader
) )
{ {
@@ -418,6 +419,56 @@ public partial class PostService(
} }
} }
public async Task<Post> PinPostAsync(Post post, Account currentUser, PostPinMode pinMode)
{
var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null)
{
if (pinMode != PostPinMode.ReplyPage) throw new InvalidOperationException("Replies can only be pinned in the reply page.");
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors of original post can pin replies.");
post.PinMode = pinMode;
}
else
{
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors can pin replies.");
post.PinMode = pinMode;
}
db.Update(post);
await db.SaveChangesAsync();
return post;
}
public async Task<Post> UnpinPostAsync(Post post, Account currentUser)
{
var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null)
{
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors of original post can unpin replies.");
}
else
{
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors can unpin posts.");
}
post.PinMode = null;
db.Update(post);
await db.SaveChangesAsync();
return post;
}
/// <summary> /// <summary>
/// Calculate the total number of votes for a post. /// Calculate the total number of votes for a post.
/// This function helps you save the new reactions. /// This function helps you save the new reactions.
@@ -770,7 +821,6 @@ public partial class PostService(
var reactSocialPoints = await db.PostReactions var reactSocialPoints = await db.PostReactions
.Include(e => e.Post) .Include(e => e.Post)
.Where(e => e.Post.Visibility == PostVisibility.Public) .Where(e => e.Post.Visibility == PostVisibility.Public)
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
.Where(e => e.Post.CreatedAt >= periodStart && e.Post.CreatedAt < periodEnd) .Where(e => e.Post.CreatedAt >= periodStart && e.Post.CreatedAt < periodEnd)
.GroupBy(e => e.PostId) .GroupBy(e => e.PostId)
.Select(e => new .Select(e => new
@@ -784,16 +834,27 @@ public partial class PostService(
featuredIds = reactSocialPoints.Select(e => e.Key).ToList(); featuredIds = reactSocialPoints.Select(e => e.Key).ToList();
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(24)); await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(4));
// Create featured record // Create featured record
var records = reactSocialPoints.Select(e => new PostFeaturedRecord var existingFeaturedPostIds = await db.PostFeaturedRecords
.Where(r => featuredIds.Contains(r.PostId))
.Select(r => r.PostId)
.ToListAsync();
var records = reactSocialPoints
.Where(p => !existingFeaturedPostIds.Contains(p.Key))
.Select(e => new PostFeaturedRecord
{
PostId = e.Key,
SocialCredits = e.Value
}).ToList();
if (records.Any())
{ {
PostId = e.Key, db.PostFeaturedRecords.AddRange(records);
SocialCredits = e.Value await db.SaveChangesAsync();
}).ToList(); }
db.PostFeaturedRecords.AddRange(records);
await db.SaveChangesAsync();
} }
var posts = await db.Posts var posts = await db.Posts

View File

@@ -245,14 +245,14 @@ public class RealmController(
members.Select(m => m.AccountId).ToList() members.Select(m => m.AccountId).ToList()
); );
members = members members = members
.Select(m => .Select(m =>
{ {
m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null; m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
return m; return m;
}) })
.OrderByDescending(m => m.Status?.IsOnline ?? false) .OrderByDescending(m => m.Status?.IsOnline ?? false)
.ToList(); .ToList();
var total = members.Count; var total = members.Count;
Response.Headers.Append("X-Total", total.ToString()); Response.Headers.Append("X-Total", total.ToString());
@@ -260,7 +260,7 @@ public class RealmController(
var result = members.Skip(offset).Take(take).ToList(); var result = members.Skip(offset).Take(take).ToList();
members = await rs.LoadMemberAccounts(result); members = await rs.LoadMemberAccounts(result);
return Ok(members.Where(m => m.Account is not null).ToList()); return Ok(members.Where(m => m.Account is not null).ToList());
} }
else else
@@ -742,4 +742,4 @@ public class RealmController(
return NoContent(); return NoContent();
} }
} }