Posts reach their audience, edits follow them, deletes leave a tombstone
OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.
- Local posts take a visibility (public, unlisted, followers-only) and a
spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
are cleared, timeline entries removed, the parent's reply count goes
down, Delete goes to the stored audience, and the object answers 410
with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
61f6ca0676
commit
a76cc34557
10 files changed
+368
-33
No files matched your search
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/post/update")]
|
||||
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/post/delete")]
|
||||
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
var (local, post) = await PublicPost(actor, postId, token);
|
||||
if (post == default)
|
||||
return NotFound();
|
||||
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||
if (WantsHtml())
|
||||
return Redirect(local.PostHtmlUrl(post.ID));
|
||||
|
||||
@@ -159,6 +159,28 @@ namespace PrivaPub.Federation.Controllers
|
||||
return (local, post);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
|
||||
return default;
|
||||
var deleted = await _dbEntities.Posts
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.ExecuteFirstAsync(token);
|
||||
if (deleted == default)
|
||||
return default;
|
||||
var tombstone = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = local.PostUri(deleted.ID),
|
||||
["type"] = "Tombstone",
|
||||
["formerType"] = "Note",
|
||||
["deleted"] = ActivityPubRenderer.Timestamp(deleted.DeletedAt.Value)
|
||||
};
|
||||
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
|
||||
}
|
||||
|
||||
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
|
||||
{
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Outbox
|
||||
{
|
||||
public interface IOutboxPublisher
|
||||
{
|
||||
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
||||
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
||||
Task PublishProfile(LocalActor actor, CancellationToken token);
|
||||
}
|
||||
|
||||
public class OutboxPublisher : IOutboxPublisher
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
||||
{
|
||||
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var inboxes = new List<string>();
|
||||
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
||||
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
|
||||
|
||||
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
|
||||
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
|
||||
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var uri in addressed)
|
||||
{
|
||||
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
|
||||
inboxes.Add(actor.InboxURL);
|
||||
}
|
||||
|
||||
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
|
||||
{
|
||||
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
|
||||
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
|
||||
inboxes.Add(parentAuthor.InboxURL);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
{
|
||||
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
if (group is { IsFederated: true })
|
||||
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
|
||||
}
|
||||
|
||||
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
var inboxes = await Audience(author, post, token);
|
||||
if (inboxes.Count > 0)
|
||||
await _delivery.Enqueue(author, inboxes, activity, token);
|
||||
}
|
||||
|
||||
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
||||
{
|
||||
var document = ActivityPubRenderer.Actor(actor);
|
||||
document.Remove("@context");
|
||||
var update = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
|
||||
["type"] = "Update",
|
||||
["actor"] = actor.Uri,
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
||||
["cc"] = new JsonArray(actor.Followers),
|
||||
["object"] = document
|
||||
};
|
||||
await _delivery.EnqueueToFollowers(actor, update, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<ILocalActorService, LocalActorService>()
|
||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
|
||||
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
||||
.AddSingleton<IActivityHandler, FollowHandler>()
|
||||
.AddSingleton<IActivityHandler, AcceptHandler>()
|
||||
|
||||
@@ -10,6 +10,7 @@ using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
|
||||
namespace PrivaPub.Services.ClientToServer.Private
|
||||
{
|
||||
@@ -23,16 +24,19 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
public class PrivateAvatarUsersService : IPrivateAvatarUsersService
|
||||
{
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<PrivateAvatarUsersService> _logger;
|
||||
|
||||
public PrivateAvatarUsersService(ILocalActorService localActors,
|
||||
IOutboxPublisher outbox,
|
||||
DbEntities dbEntities,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<PrivateAvatarUsersService> logger)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_outbox = outbox;
|
||||
_dbEntities = dbEntities;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
@@ -115,6 +119,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
avatar.Settings = ToSettings(form.Settings);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar);
|
||||
await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default);
|
||||
|
||||
result.Data = ToView(avatar);
|
||||
return result;
|
||||
|
||||
@@ -7,6 +7,7 @@ using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
@@ -24,6 +25,7 @@ namespace PrivaPub.Services
|
||||
{
|
||||
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
||||
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
|
||||
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
|
||||
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
|
||||
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
||||
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
||||
@@ -38,6 +40,7 @@ namespace PrivaPub.Services
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IContentRenderer _content;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<PostsService> _logger;
|
||||
|
||||
@@ -46,6 +49,7 @@ namespace PrivaPub.Services
|
||||
IRemoteActorService remoteActors,
|
||||
IDeliveryService delivery,
|
||||
IContentRenderer content,
|
||||
IOutboxPublisher outbox,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<PostsService> logger)
|
||||
{
|
||||
@@ -54,6 +58,7 @@ namespace PrivaPub.Services
|
||||
_remoteActors = remoteActors;
|
||||
_delivery = delivery;
|
||||
_content = content;
|
||||
_outbox = outbox;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -86,14 +91,15 @@ namespace PrivaPub.Services
|
||||
GroupUserId = author.Id,
|
||||
AuthorAccountId = author.Id,
|
||||
GroupId = group?.Id,
|
||||
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
|
||||
Visibility = isLocalOnly ? PostVisibility.Circle : LocalVisibility(form.Visibility),
|
||||
Title = form.Title,
|
||||
SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(),
|
||||
Text = form.Text,
|
||||
ContentHtml = rendered.Html,
|
||||
ContentFormat = ContentFormat.Markdown,
|
||||
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
||||
Tags = rendered.Tags.ToList(),
|
||||
HasContentWarning = form.HasContentWarning,
|
||||
HasContentWarning = form.HasContentWarning || !string.IsNullOrWhiteSpace(form.SpoilerText),
|
||||
AnsweringToPostId = parent?.ID,
|
||||
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
|
||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||
@@ -120,11 +126,8 @@ namespace PrivaPub.Services
|
||||
if (parent != default)
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
|
||||
var addressedInboxes = rendered.Mentions.Where(m => !m.IsLocal).Select(m => m.Inbox).ToList();
|
||||
if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId))
|
||||
addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL);
|
||||
await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes);
|
||||
if (group != default)
|
||||
await _outbox.Publish(author, post, create, token);
|
||||
if (group is { IsFederated: true } && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
||||
|
||||
result.Data = ToView(post);
|
||||
@@ -152,35 +155,28 @@ namespace PrivaPub.Services
|
||||
if (post == default)
|
||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||
if (post.IsLocalOnly)
|
||||
if (post.DeletedAt.HasValue)
|
||||
return result;
|
||||
var audience = await _outbox.Audience(author, post, token);
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID)
|
||||
.Modify(p => p.DeletedAt, DateTime.UtcNow)
|
||||
.Modify(p => p.Text, null)
|
||||
.Modify(p => p.ContentHtml, null)
|
||||
.Modify(p => p.Title, null)
|
||||
.Modify(p => p.SpoilerText, null)
|
||||
.Modify(p => p.Media, new List<PostMedia>())
|
||||
.Modify(p => p.Revisions, new List<PostRevision>())
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||
|
||||
if (post.Visibility == PostVisibility.Direct)
|
||||
if (audience.Count > 0)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
foreach (var uri in post.To.Concat(post.Cc).Distinct())
|
||||
{
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
if (foreign != default)
|
||||
recipients.Add(foreign.InboxURL);
|
||||
}
|
||||
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
|
||||
await _delivery.Enqueue(author, recipients, directDelete, token);
|
||||
return result;
|
||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
|
||||
await _delivery.Enqueue(author, audience, delete, token);
|
||||
}
|
||||
|
||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
|
||||
var extraInboxes = Enumerable.Empty<string>();
|
||||
if (!string.IsNullOrEmpty(post.GroupId))
|
||||
{
|
||||
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
if (group != default)
|
||||
extraInboxes = await _delivery.FollowerInboxes(group, token);
|
||||
}
|
||||
await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -190,6 +186,73 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
||||
if (author == default)
|
||||
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
||||
var post = await _dbEntities.Posts
|
||||
.Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
post.Revisions.Add(new PostRevision
|
||||
{
|
||||
Title = post.Title,
|
||||
SpoilerText = post.SpoilerText,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
EditedAt = post.EditedAt ?? post.CreationDate
|
||||
});
|
||||
var rendered = await _content.Markdown(form.Text, token);
|
||||
post.Title = form.Title;
|
||||
post.SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim();
|
||||
post.HasContentWarning = form.HasContentWarning || post.SpoilerText != default;
|
||||
post.Text = form.Text;
|
||||
post.ContentHtml = rendered.Html;
|
||||
post.Mentions = post.Visibility == PostVisibility.Direct
|
||||
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
|
||||
: rendered.Mentions.Select(ToMention).ToList();
|
||||
post.Tags = rendered.Tags.ToList();
|
||||
post.EditedAt = DateTime.UtcNow;
|
||||
post.UpdateDate = post.EditedAt;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
|
||||
if (!post.IsLocalOnly)
|
||||
{
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = post.Visibility == PostVisibility.Direct
|
||||
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
: ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
|
||||
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
var update = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"),
|
||||
["type"] = "Update",
|
||||
["actor"] = author.Uri,
|
||||
["to"] = note["to"]!.DeepClone(),
|
||||
["cc"] = note["cc"]!.DeepClone(),
|
||||
["object"] = note
|
||||
};
|
||||
await _outbox.Publish(author, post, update, token);
|
||||
}
|
||||
|
||||
result.Data = ToView(post);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(UpdatePost)}");
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -387,6 +450,13 @@ namespace PrivaPub.Services
|
||||
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
|
||||
}
|
||||
|
||||
static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch
|
||||
{
|
||||
"unlisted" => PostVisibility.Unlisted,
|
||||
"followersonly" or "private" => PostVisibility.FollowersOnly,
|
||||
_ => PostVisibility.Public
|
||||
};
|
||||
|
||||
static PostMention ToMention(ResolvedMention mention) => new()
|
||||
{
|
||||
ActorURI = mention.ActorUri,
|
||||
|
||||
Reference in new issue
Block a user