2026-10-05 02:48:35 +02:00
using MongoDB.Bson ;
2026-10-01 11:55:22 +02:00
using MongoDB.Driver ;
using MongoDB.Entities ;
using PrivaPub.Domain.Content ;
2026-10-01 12:22:08 +02:00
using PrivaPub.Domain.Media ;
2026-10-01 11:55:22 +02:00
using PrivaPub.Domain.Privacy ;
using PrivaPub.Domain.Social ;
using PrivaPub.Domain.Timelines ;
using PrivaPub.Federation.Actors ;
using PrivaPub.Federation.Outbox ;
using PrivaPub.Federation.Rendering ;
using PrivaPub.Models.Federation ;
using PrivaPub.Models.Group ;
2026-10-01 12:22:08 +02:00
using PrivaPub.Models.Media ;
2026-10-01 11:55:22 +02:00
using PrivaPub.Models.Post ;
2026-10-01 19:53:13 +02:00
using PrivaPub.Models.User ;
2026-10-01 11:55:22 +02:00
using PrivaPub.Models.Social ;
using PrivaPub.StaticServices ;
using System.Text.Json.Nodes ;
using PostEntity = PrivaPub . Models . Post . Post ;
namespace PrivaPub.Domain.Statuses
{
public sealed class StatusDraft
{
public string Text { get ; init ; }
public bool PlainText { get ; init ; }
public string Title { get ; init ; }
public string SpoilerText { get ; init ; }
public bool Sensitive { get ; init ; }
public PostVisibility Visibility { get ; init ; } = PostVisibility . Public ;
public string InReplyTo { get ; init ; }
public string GroupId { get ; init ; }
public string Language { get ; init ; }
public string ConversationId { get ; init ; }
public IReadOnlyList < string > Recipients { get ; init ; } = Array . Empty < string >();
2026-10-01 12:22:08 +02:00
public IReadOnlyList < string > MediaIds { get ; init ; }
2026-10-07 10:34:16 +02:00
// the scheduled post being published, whose media are reserved for it alone
public string ScheduledStatusId { get ; init ; }
2026-10-01 12:24:30 +02:00
public double? Latitude { get ; init ; }
public double? Longitude { get ; init ; }
public double? RangeKm { get ; init ; }
2026-10-01 18:35:20 +02:00
public PollDraft Poll { get ; init ; }
2026-10-01 18:53:28 +02:00
public string QuotedStatusId { get ; init ; }
2026-10-01 19:53:13 +02:00
public string QuotePolicy { get ; init ; }
2026-10-01 11:55:22 +02:00
}
public sealed record StatusOutcome ( PostEntity Post , int Status = StatusCodes . Status200OK , string Error = default )
{
public bool Ok => Error == default ;
public static StatusOutcome Fail ( int status , string error ) => new ( default , status , error );
}
public interface IStatusService
{
Task < StatusOutcome > Publish ( LocalActor author , StatusDraft draft , CancellationToken token );
Task < StatusOutcome > Edit ( LocalActor author , string postId , StatusDraft draft , CancellationToken token );
Task < StatusOutcome > Remove ( LocalActor author , string postId , CancellationToken token );
Task < StatusOutcome > Favourite ( LocalActor me , string postId , bool on , CancellationToken token );
Task < StatusOutcome > Reblog ( LocalActor me , string postId , bool on , PostVisibility visibility , CancellationToken token );
}
public class StatusService : IStatusService
{
const int MaxRevisions = 20 ;
readonly DbEntities _dbEntities ;
readonly ILocalActorService _localActors ;
readonly IRemoteActorService _remoteActors ;
readonly IDeliveryService _delivery ;
readonly IContentRenderer _content ;
readonly IOutboxPublisher _outbox ;
readonly IFanout _fanout ;
2026-10-01 12:22:08 +02:00
readonly IMediaService _media ;
2026-10-01 12:30:57 +02:00
readonly IGroupDistributor _groups ;
2026-10-01 18:35:20 +02:00
readonly IPollService _polls ;
2026-10-01 18:43:39 +02:00
readonly ILinkPreviews _previews ;
2026-10-01 18:53:28 +02:00
readonly IQuoteService _quotes ;
2026-10-05 11:43:58 +02:00
readonly IInteractionApprovals _approvals ;
2026-10-01 11:55:22 +02:00
public StatusService ( DbEntities dbEntities , ILocalActorService localActors , IRemoteActorService remoteActors , IDeliveryService delivery ,
2026-10-01 18:43:39 +02:00
IContentRenderer content , IOutboxPublisher outbox , IFanout fanout , IMediaService media , IGroupDistributor groups , IPollService polls ,
2026-10-05 11:43:58 +02:00
ILinkPreviews previews , IQuoteService quotes , IInteractionApprovals approvals = default )
2026-10-01 11:55:22 +02:00
{
2026-10-05 11:43:58 +02:00
_approvals = approvals ;
2026-10-01 18:43:39 +02:00
_previews = previews ;
2026-10-01 18:53:28 +02:00
_quotes = quotes ;
2026-10-01 18:35:20 +02:00
_polls = polls ;
2026-10-01 12:22:08 +02:00
_media = media ;
2026-10-01 12:30:57 +02:00
_groups = groups ;
2026-10-01 11:55:22 +02:00
_dbEntities = dbEntities ;
_localActors = localActors ;
_remoteActors = remoteActors ;
_delivery = delivery ;
_content = content ;
_outbox = outbox ;
_fanout = fanout ;
}
2026-10-04 03:48:32 +02:00
public const int MaxCharacters = 5000 ; //what the instance API advertises as max_characters, and enforced here
static StatusOutcome TooLong ( StatusDraft draft ) =>
( draft . Text ?. Length ?? 0 ) > MaxCharacters
? StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , $"Validation failed: Text character limit of {MaxCharacters} exceeded" )
: default ;
2026-10-01 11:55:22 +02:00
public async Task < StatusOutcome > Publish ( LocalActor author , StatusDraft draft , CancellationToken token )
{
2026-10-04 03:48:32 +02:00
if ( TooLong ( draft ) is { } tooLong )
return tooLong ;
2026-10-07 10:34:16 +02:00
var media = await Media ( author , draft . MediaIds , default , draft . ScheduledStatusId , token );
2026-10-01 12:22:08 +02:00
if ( media == default )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
2026-10-01 18:35:20 +02:00
if ( string . IsNullOrWhiteSpace ( draft . Text ) && media . Count == 0 && draft . Poll == default )
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Text can't be blank" );
2026-10-01 18:35:20 +02:00
if ( draft . Poll != default && _polls . Invalid ( draft . Poll ) is { } invalidPoll )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , invalidPoll );
2026-10-01 11:55:22 +02:00
LocalActor group = default ;
if (! string . IsNullOrEmpty ( draft . GroupId ))
{
var groupEntity = await _dbEntities . Groups . MatchID ( draft . GroupId ). ExecuteFirstAsync ( token );
2026-10-01 12:30:57 +02:00
if ( groupEntity == default || groupEntity . DeletionAt . HasValue || ! await MayPost ( groupEntity , author , token ))
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Group not found" );
group = _localActors . FromGroup ( groupEntity );
}
var parent = await Parent ( draft . InReplyTo , token );
if (! string . IsNullOrEmpty ( draft . InReplyTo ) && parent == default && ! IsRemoteUri ( draft . InReplyTo ))
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( parent != default && ! await VisibilityPolicy . CanSee ( parent , author . Id , token ))
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-05 06:10:25 +02:00
// its community's moderators locked the thread
if ( parent is { LockedAt : not null })
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: This thread is locked" );
2026-10-05 11:43:58 +02:00
// its author takes our reply at once, once asked, or not at all (GoToSocial's canReply)
var replyPermission = await Permission ( parent , author , InteractionKind . Reply , token );
if ( replyPermission == QuotePermission . Denied )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Its author does not take replies from you" );
2026-10-04 03:01:14 +02:00
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
if ( group == default && parent is { Visibility : PostVisibility . Circle } && ! string . IsNullOrEmpty ( parent . GroupId )
&& await _dbEntities . Groups . MatchID ( parent . GroupId ). ExecuteFirstAsync ( token ) is { DeletionAt : null } parentCircle
&& await MayPost ( parentCircle , author , token ))
group = _localActors . FromGroup ( parentCircle );
2026-10-01 11:55:22 +02:00
2026-10-01 18:53:28 +02:00
PostEntity quoted = default ;
var quotePermission = QuotePermission . Denied ;
if (! string . IsNullOrEmpty ( draft . QuotedStatusId ))
{
quoted = await _dbEntities . Posts . Match ( p => p . ID == draft . QuotedStatusId && ! p . DeletedAt . HasValue && p . ReblogOfPostId == null ). ExecuteFirstAsync ( token );
if ( quoted == default || ! await VisibilityPolicy . CanSee ( quoted , author . Id , token ))
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-01 19:53:13 +02:00
quotePermission = await _quotes . Permission ( quoted , author , token );
2026-10-01 18:53:28 +02:00
if ( quotePermission == QuotePermission . Denied )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: This post cannot be quoted" );
}
2026-10-01 12:24:30 +02:00
var located = draft . Latitude . HasValue || draft . Longitude . HasValue ;
if ( located && ( draft . Latitude is not (>= - 90 and <= 90 ) || draft . Longitude is not (>= - 180 and <= 180 ) || group != default
|| draft . Visibility == PostVisibility . Direct ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: A located post needs a valid position and no group or recipients" );
2026-10-01 12:22:08 +02:00
var rendered = draft . PlainText ? await _content . PlainText ( draft . Text ?? string . Empty , token ) : await _content . Markdown ( draft . Text ?? string . Empty , token );
2026-10-01 12:30:57 +02:00
string audienceUri = default ;
if ( group == default && ! located && draft . Visibility is PostVisibility . Public or PostVisibility . Unlisted )
foreach ( var mention in rendered . Mentions )
{
if ( mention . IsLocal )
{
var mentioned = await _dbEntities . Groups . MatchID ( mention . AccountId ). ExecuteFirstAsync ( token );
if ( mentioned is { DeletionAt : null , Kind : GroupKind . Community } && await MayPost ( mentioned , author , token ))
{
group = _localActors . FromGroup ( mentioned );
break ;
}
}
else if ( await _dbEntities . ForeignAvatars . MatchID ( mention . AccountId ). ExecuteFirstAsync ( token ) is { AvatarType : Models . User . AvatarType . Group } remoteGroup )
{
audienceUri = remoteGroup . ActorURI ;
break ;
}
}
2026-10-05 06:10:25 +02:00
// a community that banned the persona takes nothing from it, a post or a reply
var community = audienceUri ?? parent ?. AudienceURI ;
if ( community != default && await DB . Default . Find < BlockedBy >(). Match ( b => b . AvatarId == author . Id && b . ActorURI == community ). ExecuteAnyAsync ( token ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: This community banned you" );
2026-10-01 12:30:57 +02:00
var isLocalOnly = located ;
2026-10-01 12:24:30 +02:00
var visibility = located ? PostVisibility . LocalGeo
2026-10-01 12:30:57 +02:00
: group is { IsCircle : true } ? PostVisibility . Circle
2026-10-01 12:24:30 +02:00
: draft . Visibility is PostVisibility . Circle or PostVisibility . LocalGeo ? PostVisibility . Public : draft . Visibility ;
2026-10-04 03:01:14 +02:00
// asking a quoted post's author for permission would show them the circle post
if ( visibility == PostVisibility . Circle && quoted != default && quotePermission != QuotePermission . Granted )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: A circle post can only quote a post that needs no permission" );
2026-10-01 11:55:22 +02:00
var post = new PostEntity
{
GroupUserId = author . Id ,
AuthorAccountId = author . Id ,
GroupId = group ?. Id ,
Visibility = visibility ,
Title = Clean ( draft . Title ),
SpoilerText = Clean ( draft . SpoilerText ),
HasContentWarning = draft . Sensitive || Clean ( draft . SpoilerText ) != default ,
Text = draft . Text ,
ContentHtml = rendered . Html ,
ContentFormat = draft . PlainText ? ContentFormat . Plain : ContentFormat . Markdown ,
Language = Clean ( draft . Language ),
Mentions = rendered . Mentions . Select ( ToMention ). ToList (),
Tags = rendered . Tags . ToList (),
2026-10-01 12:22:08 +02:00
Media = media . Select ( ToPostMedia ). ToList (),
2026-10-01 12:30:57 +02:00
AudienceURI = audienceUri ,
2026-10-01 11:55:22 +02:00
AnsweringToPostId = parent ?. ID ,
InReplyToURI = parent ?. ObjectURI ?? ( IsRemoteUri ( draft . InReplyTo ) ? draft . InReplyTo : default ),
InReplyToAccountId = parent ?. AuthorAccountId ?? parent ?. GroupUserId ,
2026-10-05 00:22:48 +02:00
InReplyToActorURI = parent is { IsFederatedCopy : true } ? parent . ActorURI : default ,
2026-10-01 11:55:22 +02:00
IsLocalOnly = isLocalOnly ,
2026-10-01 18:35:20 +02:00
ActorURI = author . Uri ,
2026-10-01 18:53:28 +02:00
Poll = draft . Poll == default ? default : _polls . Create ( draft . Poll ),
QuoteURI = quoted ?. ObjectURI ,
QuotedPostId = quoted ?. ID ,
QuoteByConsent = quoted != default && ( quoted . QuotePolicy != default || ! quoted . IsFederatedCopy ),
2026-10-05 11:43:58 +02:00
QuoteState = quoted == default ? QuoteState . None : quotePermission == QuotePermission . Granted ? QuoteState . Accepted : QuoteState . Pending ,
Approval = replyPermission == QuotePermission . AskFirst ? ApprovalState . Pending : ApprovalState . None
2026-10-01 11:55:22 +02:00
};
post . ID = ( string ) post . GenerateNewID ();
post . ObjectURI = author . PostUri ( post . ID );
post . Url = author . PostHtmlUrl ( post . ID );
2026-10-07 10:34:16 +02:00
// the media are this post's before anything is written: another post claiming them meanwhile wins, and this one is refused
if (! await Claim ( media , post . ID , draft . ScheduledStatusId , token ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
2026-10-01 19:53:13 +02:00
post . LocalQuotePolicy = QuotePolicies . IsKnown ( draft . QuotePolicy ) ? draft . QuotePolicy : author . Settings . QuotePolicy ?? QuotePolicies . Public ;
if ( quoted is { IsFederatedCopy : false } && post . QuoteState == QuoteState . Accepted
&& await _localActors . FindById ( LocalActorKind . Person , quoted . GroupUserId , token ) is { } quotedAuthor )
post . QuoteAuthorizationURI = await _quotes . Grant ( quotedAuthor , quoted , post . ObjectURI , author . Uri , token );
2026-10-01 12:24:30 +02:00
if ( located )
{
post . Geo = new Coordinates2D ( Math . Round ( draft . Longitude . Value , 2 ), Math . Round ( draft . Latitude . Value , 2 ));
post . RangeKm = ( float ) Math . Clamp ( draft . RangeKm ?? 5 , 1 , 50 );
}
2026-10-01 11:55:22 +02:00
JsonObject create = default ;
if ( visibility == PostVisibility . Direct )
{
var conversation = await Conversation ( author , draft , rendered . Mentions , token );
if (! conversation . Ok )
return StatusOutcome . Fail ( conversation . Status , conversation . Error );
var ( dmGroup , recipients ) = ( conversation . Group , conversation . Recipients );
post . ConversationId = dmGroup . ID ;
post . ContextURI = dmGroup . ConversationURI ;
post . Mentions = recipients . Select ( r => new PostMention { ActorURI = r . Uri , Handle = "@" + r . Handle , IsLocal = r . LocalId != default , AccountId = r . LocalId ?? r . ForeignId })
. Concat ( post . Mentions )
. DistinctBy ( m => m . ActorURI )
. ToList ();
2026-10-05 20:57:43 +02:00
// to one account elsewhere, as a ChatMessage when it writes to us that way, or when its server takes a private message
// no other way (Lemmy 0.19 and Mbin)
post . AsChatMessage = recipients is [{ LocalId : null } recipient ] && ( await TakesOnlyChatMessages ( recipient . Uri , token )
|| await _dbEntities . Posts . Match ( p => p . ActorURI == recipient . Uri && p . IsFederatedCopy && p . ObjectType == "ChatMessage" ). ExecuteAnyAsync ( token ));
2026-10-01 11:55:22 +02:00
var note = ActivityPubRenderer . DirectNote ( post , author , recipients . Select ( r => ( r . Uri , r . Handle )). ToList (), dmGroup . ConversationURI );
create = ActivityPubRenderer . Create ( author , note , $"create-{post.ID}" );
post . To = Strings ( note [ "to" ]);
2026-10-05 05:37:28 +02:00
await ConversationStates . Posted ( dmGroup . ID , post . ID , author . Id , token );
2026-10-01 11:55:22 +02:00
}
else if (! isLocalOnly )
{
2026-10-06 07:13:54 +02:00
// the conversation it belongs to (FEP-7888): its parent's, or its own when it starts one
if ( group == default && visibility is PostVisibility . Public or PostVisibility . Unlisted )
post . ContextURI = parent == default ? post . ObjectURI + "/context" : parent . ContextURI ;
2026-10-01 11:55:22 +02:00
var note = ActivityPubRenderer . Note ( post , author , group , post . InReplyToURI );
create = ActivityPubRenderer . Create ( author , note , $"create-{post.ID}" );
post . To = Strings ( note [ "to" ]);
post . Cc = Strings ( note [ "cc" ]);
}
post . ActivityURI = create ?[ "id" ]?. GetValue < string >();
await DB . Default . SaveAsync ( post , token );
2026-10-04 03:48:32 +02:00
if ( parent != default && Counted . Reply ( post ))
2026-10-01 11:55:22 +02:00
await DB . Default . Update < PostEntity >(). MatchID ( parent . ID ). Modify ( b => b . Inc ( p => p . RepliesCount , 1 )). ExecuteAsync ( token );
await _fanout . Distribute ( post , token );
2026-10-01 18:35:20 +02:00
await _polls . Scheduled ( post , token );
2026-10-01 18:53:28 +02:00
if ( post . QuoteState == QuoteState . Accepted )
await DB . Default . Update < PostEntity >(). MatchID ( quoted . ID ). Modify ( b => b . Inc ( p => p . QuotesCount , 1 )). ExecuteAsync ( token );
if ( post . QuoteState == QuoteState . Pending && create ?[ "object" ] is JsonObject quotingNote )
await _quotes . Request ( author , post , quoted , quotingNote , token );
2026-10-01 18:43:39 +02:00
await _previews . Wanted ( post , token );
2026-10-05 11:43:58 +02:00
// a reply its parent's author must approve goes to that author alone, as a request, until it is approved
if ( create != default && post . Approval == ApprovalState . Pending )
await _approvals . Ask ( author , parent , InteractionKind . Reply , create [ "object" ]. AsObject (), post . ID , token );
else if ( create != default )
2026-10-01 11:55:22 +02:00
await _outbox . Publish ( author , post , create , token );
2026-10-05 11:43:58 +02:00
if ( create != default && post . Approval != ApprovalState . Pending && group is { IsCircle : false } && visibility is PostVisibility . Public or PostVisibility . Unlisted )
2026-10-05 04:12:53 +02:00
await _groups . Announce ( group , create , post . ObjectURI , isNewPost : true , token );
2026-10-01 11:55:22 +02:00
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Edit ( LocalActor author , string postId , StatusDraft draft , CancellationToken token )
{
var post = await Own ( author , postId , token );
if ( post == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-04 03:48:32 +02:00
if ( TooLong ( draft ) is { } tooLong )
return tooLong ;
2026-10-07 10:34:16 +02:00
var media = draft . MediaIds == default ? default : await Media ( author , draft . MediaIds , post . ID , default , token );
2026-10-01 12:22:08 +02:00
if ( draft . MediaIds != default && media == default )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
if ( string . IsNullOrWhiteSpace ( draft . Text ) && ( media ?? new List < MediaAttachment >()). Count == 0 && post . Media . Count == 0 )
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Text can't be blank" );
post . Revisions . Add ( new PostRevision
{
Title = post . Title ,
SpoilerText = post . SpoilerText ,
ContentHtml = post . ContentHtml ,
HasContentWarning = post . HasContentWarning ,
EditedAt = post . EditedAt ?? post . CreationDate
});
if ( post . Revisions . Count > MaxRevisions )
post . Revisions . RemoveRange ( 0 , post . Revisions . Count - MaxRevisions );
var plain = draft . PlainText || post . ContentFormat == ContentFormat . Plain ;
2026-10-01 12:22:08 +02:00
var rendered = plain ? await _content . PlainText ( draft . Text ?? string . Empty , token ) : await _content . Markdown ( draft . Text ?? string . Empty , token );
if ( media != default )
{
2026-10-07 10:34:16 +02:00
if (! await Claim ( media , post . ID , default , token ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
2026-10-01 12:22:08 +02:00
post . Media = media . Select ( ToPostMedia ). ToList ();
2026-10-07 10:31:14 +02:00
// what the edit left out is no longer held by anything
var kept = media . Select ( m => m . ID ). ToList ();
await _media . Trash ( m => m . PostId == post . ID && ! kept . Contains ( m . ID ), "edited out" , token );
2026-10-01 12:22:08 +02:00
}
2026-10-01 11:55:22 +02:00
post . Title = draft . Title == default ? post . Title : Clean ( draft . Title );
post . SpoilerText = Clean ( draft . SpoilerText );
post . HasContentWarning = draft . Sensitive || post . SpoilerText != default ;
post . Text = draft . Text ;
post . ContentHtml = rendered . Html ;
post . Language = Clean ( draft . Language ) ?? post . Language ;
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 );
2026-10-05 04:04:08 +02:00
await Fanout . Edited ( post , token );
2026-10-01 11:55:22 +02:00
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 (),
2026-10-05 02:48:35 +02:00
["id"] = author . ActivityUri ( $"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeMilliseconds()}" ),
2026-10-01 11:55:22 +02:00
["type"] = "Update" ,
["actor"] = author . Uri ,
["to"] = note [ "to" ]!. DeepClone (),
["cc"] = note [ "cc" ]!. DeepClone (),
["object"] = note
};
await _outbox . Publish ( author , post , update , token );
2026-10-01 12:30:57 +02:00
if ( group is { IsCircle : false })
await _groups . Announce ( group , update , post . ObjectURI , isNewPost : false , token );
2026-10-01 11:55:22 +02:00
}
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Remove ( LocalActor author , string postId , CancellationToken token )
{
var post = await _dbEntities . Posts . Match ( p => p . ID == postId && p . GroupUserId == author . Id && ! p . IsFederatedCopy ). ExecuteFirstAsync ( token );
if ( post == default || post . DeletedAt . HasValue )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( post . ReblogOfPostId != default )
return await Reblog ( author , post . ReblogOfPostId , false , post . Visibility , token );
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 );
2026-10-05 04:04:08 +02:00
await Fanout . Deleting ( post , token );
2026-10-07 10:31:14 +02:00
await _media . Trash ( m => m . PostId == post . ID , "post deleted" , token );
2026-10-01 11:55:22 +02:00
await DB . Default . DeleteAsync < TimelineEntry >( e => e . PostId == post . ID || e . ReblogOfPostId == post . ID );
2026-10-04 03:48:32 +02:00
// boosts of it end with it, as on Mastodon, so no count keeps them
await DB . Default . Update < PostEntity >(). Match ( p => p . ReblogOfPostId == post . ID && ! p . DeletedAt . HasValue )
. Modify ( p => p . DeletedAt , DateTime . UtcNow ). ExecuteAsync ( token );
if (! string . IsNullOrEmpty ( post . AnsweringToPostId ) && Counted . Reply ( post ))
2026-10-01 11:55:22 +02:00
await DB . Default . Update < PostEntity >(). MatchID ( post . AnsweringToPostId ). Modify ( b => b . Inc ( p => p . RepliesCount , - 1 )). ExecuteAsync ( token );
2026-10-04 03:48:32 +02:00
await DB . Default . DeleteAsync < Models . Social . Pin >( p => p . PostId == post . ID ); //a deleted post no longer takes a pin
2026-10-01 11:55:22 +02:00
if ( audience . Count > 0 )
{
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 ()));
2026-10-04 03:01:14 +02:00
await _outbox . Publish ( author , post , delete , token ); //the post in hand still has its group and mentions
2026-10-01 12:30:57 +02:00
var group = string . IsNullOrEmpty ( post . GroupId ) ? default : await _localActors . FindById ( LocalActorKind . Group , post . GroupId , token );
if ( group is { IsCircle : false })
await _groups . Announce ( group , delete , post . ObjectURI , isNewPost : false , token );
2026-10-01 11:55:22 +02:00
}
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Favourite ( LocalActor me , string postId , bool on , CancellationToken token )
{
var post = await Visible ( me , postId , token );
if ( post == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-05 11:43:58 +02:00
var likePermission = on ? await Permission ( post , me , InteractionKind . Like , token ) : QuotePermission . Granted ;
if ( likePermission == QuotePermission . Denied )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Its author does not take likes from you" );
2026-10-01 11:55:22 +02:00
2026-10-05 02:48:35 +02:00
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
var favourite = default ( Favourite );
2026-10-01 11:55:22 +02:00
if ( on )
{
2026-10-05 11:43:58 +02:00
favourite = new Favourite
{
ID = ObjectId . GenerateNewId (). ToString (), AccountId = me . Id , ActorURI = me . Uri , PostId = post . ID ,
Approval = likePermission == QuotePermission . AskFirst ? ApprovalState . Pending : ApprovalState . None
};
2026-10-05 02:48:35 +02:00
favourite . ActivityURI = me . ActivityUri ( $"like-{favourite.ID}" );
2026-10-01 11:55:22 +02:00
try
{
2026-10-05 02:48:35 +02:00
await DB . Default . SaveAsync ( favourite , token );
2026-10-01 11:55:22 +02:00
}
catch ( MongoWriteException ex ) when ( ex . WriteError ?. Category == ServerErrorCategory . DuplicateKey )
{
return new StatusOutcome ( post );
}
await DB . Default . Update < PostEntity >(). MatchID ( post . ID ). Modify ( b => b . Inc ( p => p . FavouritesCount , 1 )). ExecuteAsync ( token );
post . FavouritesCount ++;
if (! post . IsFederatedCopy )
await Notifications . Add ( post . GroupUserId , NotificationType . Favourite , me . Id , me . Uri , post . ID , token );
}
else
{
2026-10-05 02:48:35 +02:00
favourite = await DB . Default . Find < Favourite >(). Match ( f => f . AccountId == me . Id && f . PostId == post . ID ). ExecuteFirstAsync ( token );
if ( favourite == default || ( await DB . Default . DeleteAsync < Favourite >( favourite . ID )). DeletedCount == 0 )
2026-10-01 11:55:22 +02:00
return new StatusOutcome ( post );
await DB . Default . Update < PostEntity >(). MatchID ( post . ID ). Modify ( b => b . Inc ( p => p . FavouritesCount , - 1 )). ExecuteAsync ( token );
post . FavouritesCount --;
}
if ( post . IsFederatedCopy && await AuthorInbox ( post , token ) is { } inbox )
{
var like = new JsonObject
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
2026-10-05 02:48:35 +02:00
["id"] = favourite . ActivityURI ?? me . ActivityUri ( $"like-{post.ID}" ), //a favourite from before every Like had its own id
2026-10-01 11:55:22 +02:00
["type"] = "Like" ,
["actor"] = me . Uri ,
["object"] = post . ObjectURI
};
2026-10-05 11:43:58 +02:00
if ( on && favourite . Approval == ApprovalState . Pending )
await _approvals . Ask ( me , post , InteractionKind . Like , like , favourite . ID , token );
else
await _delivery . Enqueue ( me , new [] { inbox }, on ? like : Undo ( me , like , $"undo-like-{favourite.ID}" ), token );
2026-10-01 11:55:22 +02:00
}
return new StatusOutcome ( post );
}
2026-10-05 11:43:58 +02:00
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
async Task < QuotePermission > Permission ( PostEntity target , LocalActor actor , InteractionKind kind , CancellationToken token ) =>
target is { IsFederatedCopy : true } && _approvals != default ? await _approvals . Judge ( target , actor , kind , token ) : QuotePermission . Granted ;
2026-10-01 11:55:22 +02:00
public async Task < StatusOutcome > Reblog ( LocalActor me , string postId , bool on , PostVisibility visibility , CancellationToken token )
{
var original = await Visible ( me , postId , token );
if ( original ?. ReblogOfPostId != default )
original = await Visible ( me , original . ReblogOfPostId , token );
if ( original == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( original . Visibility is not ( PostVisibility . Public or PostVisibility . Unlisted ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: This post can't be boosted" );
2026-10-05 11:43:58 +02:00
var announcePermission = on ? await Permission ( original , me , InteractionKind . Announce , token ) : QuotePermission . Granted ;
if ( announcePermission == QuotePermission . Denied )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Its author does not take boosts from you" );
2026-10-01 11:55:22 +02:00
var existing = await _dbEntities . Posts . Match ( p => p . ReblogOfPostId == original . ID && p . AuthorAccountId == me . Id && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
2026-10-05 02:48:35 +02:00
// each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no
// server (and no delivery queue) takes for the one already undone; an unboost undoes the boost it ends
var reblogId = existing ?. ID ?? ObjectId . GenerateNewId (). ToString ();
var announceId = existing ?. ActivityURI ?? me . ActivityUri ( $"announce-{reblogId}" );
2026-10-01 11:55:22 +02:00
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
["id"] = announceId ,
["type"] = "Announce" ,
["actor"] = me . Uri ,
["published"] = ActivityPubRenderer . Timestamp ( DateTime . UtcNow ),
["to"] = new JsonArray ( visibility == PostVisibility . Unlisted ? me . Followers : ActivityPubRenderer . Public ),
["cc"] = new JsonArray ( visibility == PostVisibility . Unlisted ? ActivityPubRenderer . Public : me . Followers , original . ActorURI ),
["object"] = original . ObjectURI
};
if ( on )
{
if ( existing != default )
return new StatusOutcome ( existing );
var reblog = new PostEntity
{
2026-10-05 02:48:35 +02:00
ID = reblogId ,
2026-10-01 11:55:22 +02:00
GroupUserId = me . Id ,
AuthorAccountId = me . Id ,
ReblogOfPostId = original . ID ,
Visibility = visibility == PostVisibility . Unlisted ? PostVisibility . Unlisted : PostVisibility . Public ,
ObjectURI = announceId ,
ActivityURI = announceId ,
ActorURI = me . Uri ,
To = Strings ( announce [ "to" ]),
2026-10-05 11:43:58 +02:00
Cc = Strings ( announce [ "cc" ]),
Approval = announcePermission == QuotePermission . AskFirst ? ApprovalState . Pending : ApprovalState . None
2026-10-01 11:55:22 +02:00
};
try
{
await DB . Default . SaveAsync ( reblog , token );
}
catch ( MongoWriteException ex ) when ( ex . WriteError ?. Category == ServerErrorCategory . DuplicateKey )
{
return new StatusOutcome ( await _dbEntities . Posts . Match ( p => p . ObjectURI == announceId ). ExecuteFirstAsync ( token ));
}
2026-10-05 02:48:35 +02:00
// two boosts at once: the first one saved stands, and the other goes
var boosts = await _dbEntities . Posts . Match ( p => p . ReblogOfPostId == original . ID && p . AuthorAccountId == me . Id && ! p . DeletedAt . HasValue )
. Sort ( p => p . ID , Order . Ascending ). ExecuteAsync ( token );
if ( boosts . Count > 1 && boosts [ 0 ]. ID != reblog . ID )
{
await DB . Default . DeleteAsync < PostEntity >( reblog . ID );
return new StatusOutcome ( boosts [ 0 ]);
}
2026-10-01 11:55:22 +02:00
await DB . Default . Update < PostEntity >(). MatchID ( original . ID ). Modify ( b => b . Inc ( p => p . ReblogsCount , 1 )). ExecuteAsync ( token );
if (! original . IsFederatedCopy )
await Notifications . Add ( original . GroupUserId , NotificationType . Reblog , me . Id , me . Uri , original . ID , token );
await _fanout . Distribute ( reblog , token );
2026-10-05 11:43:58 +02:00
if ( reblog . Approval == ApprovalState . Pending )
await _approvals . Ask ( me , original , InteractionKind . Announce , announce , reblog . ID , token );
else
await _delivery . EnqueueToFollowers ( me , announce , token , await AuthorInbox ( original , token , shared : true ) is { } inbox ? new [] { inbox } : default );
2026-10-01 11:55:22 +02:00
return new StatusOutcome ( reblog );
}
if ( existing == default )
return new StatusOutcome ( original );
2026-10-05 04:04:08 +02:00
await Fanout . Deleting ( existing , token );
2026-10-01 11:55:22 +02:00
await DB . Default . DeleteAsync < PostEntity >( existing . ID );
await DB . Default . DeleteAsync < TimelineEntry >( e => e . PostId == existing . ID );
await DB . Default . Update < PostEntity >(). MatchID ( original . ID ). Modify ( b => b . Inc ( p => p . ReblogsCount , - 1 )). ExecuteAsync ( token );
2026-10-05 02:48:35 +02:00
await _delivery . EnqueueToFollowers ( me , Undo ( me , announce , $"undo-announce-{existing.ID}" ), token ,
2026-10-05 02:57:33 +02:00
await AuthorInbox ( original , token , shared : true ) is { } authorInbox ? new [] { authorInbox } : default );
2026-10-01 11:55:22 +02:00
return new StatusOutcome ( original );
}
sealed record Recipient ( string Uri , string Handle , string LocalId , string ForeignId , string Inbox );
2026-10-05 20:57:43 +02:00
// The one place PrivaPub decides by a server's software (owner decision 2026-10-05, G-0008): Lemmy before 1.0 and
// Mbin answer a direct Note 400 or drop it, and their actors say nothing of what they take, so the first message to
// one of their accounts goes as a ChatMessage, as NodeInfo names them
async Task < bool > TakesOnlyChatMessages ( string actorUri , CancellationToken token )
{
if (! Uri . TryCreate ( actorUri , UriKind . Absolute , out var uri ))
return false ;
var host = uri . Host ;
var instance = await DB . Default . Find < Models . Jobs . RemoteInstance >(). Match ( i => i . Host == host ). ExecuteFirstAsync ( token );
return instance ?. Software ?. ToLowerInvariant () switch
{
"mbin" => true ,
"lemmy" => instance . SoftwareVersion is { } version && version . StartsWith ( "0." , StringComparison . Ordinal ),
_ => false
};
}
2026-10-01 11:55:22 +02:00
sealed record ConversationResult ( DmGroup Group , IReadOnlyList < Recipient > Recipients , int Status = StatusCodes . Status200OK , string Error = default )
{
public bool Ok => Error == default ;
}
async Task < ConversationResult > Conversation ( LocalActor author , StatusDraft draft , IReadOnlyList < ResolvedMention > mentioned , CancellationToken token )
{
DmGroup dmGroup = default ;
if (! string . IsNullOrEmpty ( draft . ConversationId ))
{
dmGroup = await _dbEntities . DmGroups . MatchID ( draft . ConversationId ). ExecuteFirstAsync ( token );
if ( dmGroup == default || ! dmGroup . Members . Any ( m => ! m . IsForeign && m . AvatarId == author . Id ))
return new ( default , default , StatusCodes . Status404NotFound , "Conversation not found" );
}
else
{
var members = new List < GroupMember > { new () { AvatarId = author . Id } };
foreach ( var handle in draft . Recipients . Distinct ( StringComparer . OrdinalIgnoreCase ))
{
var member = await ResolveRecipient ( handle , token );
if ( member == default )
return new ( default , default , StatusCodes . Status404NotFound , $"Recipient '{handle}' not found" );
if ( members . All ( m => m . AvatarId != member . AvatarId ))
members . Add ( member );
}
foreach ( var mention in mentioned . Where ( m => m . AccountId != author . Id ))
{
var member = mention . IsLocal ? new GroupMember { AvatarId = mention . AccountId } : new GroupMember { AvatarId = mention . ActorUri , IsForeign = true };
if ( members . All ( m => m . AvatarId != member . AvatarId ))
members . Add ( member );
}
if ( members . Count < 2 )
return new ( default , default , StatusCodes . Status422UnprocessableEntity , "Validation failed: A direct message needs at least one recipient" );
var key = DmGroup . KeyOf ( members );
dmGroup = await _dbEntities . DmGroups . Match ( g => g . ParticipantsKey == key && ! g . DeletionAt . HasValue ). ExecuteFirstAsync ( token );
if ( dmGroup == default )
{
dmGroup = new DmGroup { Members = members , ParticipantsKey = key };
dmGroup . ID = ( string ) dmGroup . GenerateNewID ();
dmGroup . ConversationURI = author . ConversationUri ( dmGroup . ID );
await DB . Default . SaveAsync ( dmGroup , token );
}
}
var recipients = new List < Recipient >();
foreach ( var member in dmGroup . Members . Where ( m => !( m . AvatarId == author . Id && ! m . IsForeign )))
{
if ( member . IsForeign )
{
var foreign = await _dbEntities . ForeignAvatars . Match ( a => a . ActorURI == member . AvatarId ). ExecuteFirstAsync ( token );
if ( foreign != default )
recipients . Add ( new Recipient ( foreign . ActorURI , $"{foreign.UserName}@{foreign.Domain}" , default , foreign . ID , foreign . InboxURL ));
}
else if ( await _localActors . FindById ( LocalActorKind . Person , member . AvatarId , token ) is { } local )
recipients . Add ( new Recipient ( local . Uri , local . Handle , local . Id , default , default ));
}
return new ( dmGroup , recipients );
}
async Task < GroupMember > ResolveRecipient ( string recipient , CancellationToken token )
{
var handle = recipient . Trim (). TrimStart ( '@' );
if (! handle . Contains ( '@' ) || handle . EndsWith ( "@" + new Uri ( _localActors . BaseAddress ). Authority , StringComparison . OrdinalIgnoreCase ))
{
var local = await _localActors . FindByUserName ( handle . Split ( '@' )[ 0 ], token );
return local is { Kind : LocalActorKind . Person } ? new GroupMember { AvatarId = local . Id } : default ;
}
var actorUri = await _remoteActors . ResolveHandle ( handle , token );
var foreign = actorUri == default ? default : await _remoteActors . GetActor ( actorUri , refresh : false , token );
return foreign == default ? default : new GroupMember { AvatarId = foreign . ActorURI , IsForeign = true };
}
async Task < PostEntity > Parent ( string reference , CancellationToken token )
{
if ( string . IsNullOrEmpty ( reference ))
return default ;
return IsRemoteUri ( reference )
? await _dbEntities . Posts . Match ( p => p . ObjectURI == reference && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token )
: await _dbEntities . Posts . Match ( p => p . ID == reference && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
}
async Task < PostEntity > Own ( LocalActor author , string postId , CancellationToken token ) =>
await _dbEntities . Posts . Match ( p => p . ID == postId && p . GroupUserId == author . Id && ! p . IsFederatedCopy && ! p . DeletedAt . HasValue && p . ReblogOfPostId == null )
. ExecuteFirstAsync ( token );
async Task < PostEntity > Visible ( LocalActor me , string postId , CancellationToken token )
{
var post = string . IsNullOrEmpty ( postId ) ? default : await _dbEntities . Posts . Match ( p => p . ID == postId && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
return post != default && await VisibilityPolicy . CanSee ( post , me . Id , token ) ? post : default ;
}
2026-10-05 02:57:33 +02:00
// shared: what goes to the author's followers too (a boost) goes to its server's shared inbox, so the server gets
// one copy, as Mastodon sends it; two copies at once race in servers that count as they process (Misskey)
async Task < string > AuthorInbox ( PostEntity post , CancellationToken token , bool shared = false )
2026-10-01 11:55:22 +02:00
{
if (! post . IsFederatedCopy || string . IsNullOrEmpty ( post . AuthorAccountId ))
return default ;
2026-10-05 02:57:33 +02:00
var author = await _dbEntities . ForeignAvatars . MatchID ( post . AuthorAccountId ). ExecuteFirstAsync ( token );
return shared && ! string . IsNullOrEmpty ( author ?. SharedInboxURL ) ? author . SharedInboxURL : author ?. InboxURL ;
2026-10-01 11:55:22 +02:00
}
2026-10-01 12:30:57 +02:00
async Task < bool > MayPost ( Models . Group . Group group , LocalActor author , CancellationToken token )
{
var member = group . Members . FirstOrDefault ( m => ! m . IsForeign && m . AvatarId == author . Id );
if ( group . Kind == GroupKind . Circle )
return member != default ;
return group . PostingPolicy switch
{
PostingPolicy . Anyone => true ,
PostingPolicy . Moderators => member ?. Role is GroupRole . Owner or GroupRole . Moderator ,
_ => member != default || await _dbEntities . Followings
. Match ( f => f . AvatarId == author . Id && f . TargetAccountId == group . ID && f . State == FollowState . Accepted ). ExecuteAnyAsync ( token )
};
}
2026-10-07 10:34:16 +02:00
// the author's own uploads, not yet another post's (nor held by another scheduled post), nor trashed, nor pictures
async Task < List < MediaAttachment >> Media ( LocalActor author , IReadOnlyList < string > ids , string postId , string scheduledId , CancellationToken token )
2026-10-01 12:22:08 +02:00
{
if ( ids == default || ids . Count == 0 )
return new List < MediaAttachment >();
if ( ids . Count > 4 )
return default ;
var wanted = ids . Distinct (). ToList ();
var found = await DB . Default . Find < MediaAttachment >()
2026-10-07 10:31:14 +02:00
. Match ( m => wanted . Contains ( m . ID ) && m . OwnerAvatarId == author . Id && ( m . PostId == null || m . PostId == postId )
2026-10-07 10:34:16 +02:00
&& ( m . ScheduledStatusId == null || m . ScheduledStatusId == scheduledId ) && m . TrashedAt == null && m . ProfileOfAvatarId == null )
2026-10-01 12:22:08 +02:00
. ExecuteAsync ( token );
return found . Count == wanted . Count ? wanted . Select ( id => found . First ( m => m . ID == id )). ToList () : default ;
}
2026-10-07 10:34:16 +02:00
// attaches the media in one conditional update, true when all of them are the post's; when another post took one
// meanwhile, the ones this call took go back as they were
static async Task < bool > Claim ( IReadOnlyList < MediaAttachment > media , string postId , string scheduledId , CancellationToken token )
2026-10-01 12:22:08 +02:00
{
var ids = media . Select ( m => m . ID ). ToList ();
2026-10-07 10:34:16 +02:00
if ( ids . Count == 0 )
return true ;
var claimed = await DB . Default . Update < MediaAttachment >()
. Match ( m => ids . Contains ( m . ID ) && ( m . PostId == null || m . PostId == postId ) && m . TrashedAt == null
&& ( m . ScheduledStatusId == null || m . ScheduledStatusId == scheduledId ))
. Modify ( m => m . PostId , postId ). Modify ( m => m . AttachedAt , DateTime . UtcNow ). ExecuteAsync ( token );
if ( claimed . MatchedCount == ids . Count )
return true ;
var taken = media . Where ( m => m . PostId == null ). Select ( m => m . ID ). ToList ();
await DB . Default . Update < MediaAttachment >(). Match ( m => taken . Contains ( m . ID ) && m . PostId == postId )
. Modify ( m => m . PostId , null ). Modify ( m => m . AttachedAt , null ). ExecuteAsync ( token );
return false ;
2026-10-01 12:22:08 +02:00
}
PostMedia ToPostMedia ( MediaAttachment attachment ) => new ()
{
AttachmentId = attachment . ID ,
ContentType = attachment . ContentType ,
URL = _media . Url ( attachment . FilePath ),
PreviewURL = _media . Url ( attachment . PreviewPath ?? attachment . FilePath ),
Description = attachment . Description ,
Blurhash = attachment . Blurhash ,
Width = attachment . Width ,
Height = attachment . Height ,
Focus = attachment . Focus
};
2026-10-01 11:55:22 +02:00
static JsonObject Undo ( LocalActor actor , JsonObject inner , string activityId ) => new ()
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
["id"] = actor . ActivityUri ( activityId ),
["type"] = "Undo" ,
["actor"] = actor . Uri ,
["object"] = inner . DeepClone ()
};
static PostMention ToMention ( ResolvedMention mention ) => new ()
{
ActorURI = mention . ActorUri ,
Handle = "@" + mention . Handle ,
IsLocal = mention . IsLocal ,
AccountId = mention . AccountId
};
static bool IsRemoteUri ( string reference ) =>
reference != default && ( reference . StartsWith ( "https://" , StringComparison . OrdinalIgnoreCase ) || reference . StartsWith ( "http://" , StringComparison . OrdinalIgnoreCase ));
static string Clean ( string value ) => string . IsNullOrWhiteSpace ( value ) ? default : value . Trim ();
static List < string > Strings ( JsonNode node ) =>
node is JsonArray array ? array . Select ( n => n ?. GetValue < string >()). Where ( s => s != default ). ToList () : new List < string >();
}
}