Pinned post

This commit is contained in:
2025-08-25 13:37:25 +08:00
parent 63653680ba
commit 915054fce0
8 changed files with 2239 additions and 32 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";

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; } = [];

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();
} }
@@ -117,6 +118,13 @@ public class PostController(
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,

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.

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());