[backend/core] Replace MFM line endings during user/note ingestion
This saves significant amounts of time & memory during parsing.
This commit is contained in:
parent
a6b5b4c69f
commit
9865f3dde7
9 changed files with 154 additions and 148 deletions
|
@ -400,17 +400,15 @@ public class StatusController(
|
|||
|
||||
if (token.AutoDetectQuotes && request.Text != null)
|
||||
{
|
||||
var parsed = MfmParser.Parse(request.Text).AsEnumerable();
|
||||
quoteUri = MfmParser.Parse(request.Text).LastOrDefault() switch
|
||||
var parsed = MfmParser.Parse(request.Text);
|
||||
quoteUri = parsed.LastOrDefault() switch
|
||||
{
|
||||
MfmUrlNode urlNode => urlNode.Url,
|
||||
MfmLinkNode linkNode => linkNode.Url,
|
||||
_ => quoteUri
|
||||
};
|
||||
|
||||
if (quoteUri != null)
|
||||
parsed = parsed.SkipLast(1);
|
||||
newText = parsed.Serialize();
|
||||
newText = quoteUri != null ? parsed.SkipLast(1).Serialize() : parsed.Serialize();
|
||||
}
|
||||
|
||||
if (request is { Sensitive: true, MediaIds.Count: > 0 })
|
||||
|
|
|
@ -21,7 +21,7 @@ public class MentionsResolver(IOptions<Config.InstanceSection> config) : ISingle
|
|||
SplitDomainMapping splitDomainMapping
|
||||
)
|
||||
{
|
||||
var nodes = MfmParser.Parse(mfm);
|
||||
var nodes = MfmParser.Parse(mfm.ReplaceLineEndings("\n"));
|
||||
ResolveMentions(nodes.AsSpan(), host, mentionCache, splitDomainMapping);
|
||||
return nodes.Serialize();
|
||||
}
|
||||
|
|
|
@ -59,6 +59,9 @@ public class MfmConverter(
|
|||
// Ensure compatibility with AP servers that send non-breaking space characters instead of regular spaces
|
||||
html = html.Replace("\u00A0", " ");
|
||||
|
||||
// Ensure compatibility with AP servers that send CRLF or CR instead of LF-style newlines
|
||||
html = html.ReplaceLineEndings("\n");
|
||||
|
||||
var dom = await new HtmlParser().ParseDocumentAsync(html);
|
||||
if (dom.Body == null) return new HtmlMfmData("", media);
|
||||
|
||||
|
@ -87,7 +90,7 @@ public class MfmConverter(
|
|||
}
|
||||
|
||||
public async Task<MfmHtmlData> ToHtmlAsync(
|
||||
IEnumerable<IMfmNode> nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
||||
IMfmNode[] nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
||||
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
|
||||
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||
)
|
||||
|
@ -95,8 +98,7 @@ public class MfmConverter(
|
|||
var context = BrowsingContext.New();
|
||||
var document = await context.OpenNewAsync();
|
||||
var element = document.CreateElement(rootElement);
|
||||
var nodeList = nodes.ToList();
|
||||
var hasContent = nodeList.Count > 0;
|
||||
var hasContent = nodes.Length > 0;
|
||||
|
||||
if (replyInaccessible)
|
||||
{
|
||||
|
@ -115,7 +117,8 @@ public class MfmConverter(
|
|||
}
|
||||
|
||||
var usedMedia = new List<MfmInlineMedia>();
|
||||
foreach (var node in nodeList) element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
|
||||
foreach (var node in nodes)
|
||||
element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
|
||||
|
||||
if (quoteUri != null)
|
||||
{
|
||||
|
@ -170,7 +173,8 @@ public class MfmConverter(
|
|||
}
|
||||
|
||||
private INode FromMfmNode(
|
||||
IDocument document, IMfmNode node, List<Note.MentionedUser> mentions, string? host, List<MfmInlineMedia> usedMedia,
|
||||
IDocument document, IMfmNode node, List<Note.MentionedUser> mentions, string? host,
|
||||
List<MfmInlineMedia> usedMedia,
|
||||
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||
)
|
||||
{
|
||||
|
@ -413,7 +417,8 @@ public class MfmConverter(
|
|||
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||
)
|
||||
{
|
||||
foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
|
||||
foreach (var node in parent.Children)
|
||||
element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
|
||||
}
|
||||
|
||||
private IElement CreateInlineFormattingElement(IDocument document, string name)
|
||||
|
|
|
@ -187,7 +187,7 @@ public partial class EmojiService(
|
|||
return await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == name);
|
||||
}
|
||||
|
||||
public async Task<List<Emoji>> ResolveEmojiAsync(IEnumerable<IMfmNode> nodes)
|
||||
public async Task<List<Emoji>> ResolveEmojiAsync(IMfmNode[] nodes)
|
||||
{
|
||||
var list = new List<MfmEmojiCodeNode>();
|
||||
ResolveChildren(nodes, ref list);
|
||||
|
@ -195,7 +195,7 @@ public partial class EmojiService(
|
|||
}
|
||||
|
||||
private static void ResolveChildren(
|
||||
IEnumerable<IMfmNode> nodes, ref List<MfmEmojiCodeNode> list
|
||||
Span<IMfmNode> nodes, ref List<MfmEmojiCodeNode> list
|
||||
)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
|
|
|
@ -58,6 +58,7 @@ public class NoteService(
|
|||
public required User User;
|
||||
public required Note.NoteVisibility Visibility;
|
||||
public string? Text;
|
||||
public IMfmNode[]? ParsedText;
|
||||
public string? Cw;
|
||||
public Note? Reply;
|
||||
public Note? Renote;
|
||||
|
@ -78,6 +79,7 @@ public class NoteService(
|
|||
{
|
||||
public required Note Note;
|
||||
public string? Text;
|
||||
public IMfmNode[]? ParsedText;
|
||||
public string? Cw;
|
||||
public IReadOnlyCollection<DriveFile>? Attachments;
|
||||
public Poll? Poll;
|
||||
|
@ -126,6 +128,7 @@ public class NoteService(
|
|||
if (data.Poll is { Choices.Count: < 2 })
|
||||
throw GracefulException.UnprocessableEntity("Polls must have at least two options");
|
||||
|
||||
data.ParsedText = data.Text != null ? MfmParser.Parse(data.Text.ReplaceLineEndings("\n")) : null;
|
||||
policySvc.CallRewriteHooks(data, IRewritePolicy.HookLocationEnum.PreLogic);
|
||||
|
||||
if (!data.LocalOnly && (data.Renote is { LocalOnly: true } || data.Reply is { LocalOnly: true }))
|
||||
|
@ -154,7 +157,7 @@ public class NoteService(
|
|||
}
|
||||
|
||||
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
|
||||
data.ResolvedMentions ?? await ResolveNoteMentionsAsync(data.Text);
|
||||
data.ResolvedMentions ?? await ResolveNoteMentionsAsync(data.ParsedText);
|
||||
|
||||
// ReSharper disable once EntityFramework.UnsupportedServerSideFunctionCall
|
||||
if (mentionedUserIds.Count > 0)
|
||||
|
@ -167,18 +170,17 @@ public class NoteService(
|
|||
throw GracefulException.Forbidden($"You're not allowed to interact with @{blockAcct}");
|
||||
}
|
||||
|
||||
IMfmNode[]? nodes = null;
|
||||
if (data.Text != null && string.IsNullOrWhiteSpace(data.Text))
|
||||
{
|
||||
data.Text = null;
|
||||
data.Text = null;
|
||||
data.ParsedText = null;
|
||||
}
|
||||
else if (data.Text != null)
|
||||
{
|
||||
nodes = MfmParser.Parse(data.Text);
|
||||
mentionsResolver.ResolveMentions(nodes.AsSpan(), data.User.Host, mentions, splitDomainMapping);
|
||||
data.Text = nodes.Serialize();
|
||||
mentionsResolver.ResolveMentions(data.ParsedText, data.User.Host, mentions, splitDomainMapping);
|
||||
}
|
||||
|
||||
data.Cw = data.Cw?.Trim();
|
||||
if (data.Cw != null && string.IsNullOrWhiteSpace(data.Cw))
|
||||
data.Cw = null;
|
||||
|
||||
|
@ -196,7 +198,7 @@ public class NoteService(
|
|||
.ExecuteUpdateAsync(p => p.SetProperty(i => i.IsSensitive, _ => true));
|
||||
}
|
||||
|
||||
var tags = ResolveHashtags(data.Text, data.ASNote);
|
||||
var tags = ResolveHashtags(data.ParsedText, data.ASNote);
|
||||
if (tags.Count > 0 && data.Text != null && data.ASNote != null)
|
||||
{
|
||||
// @formatter:off
|
||||
|
@ -211,9 +213,9 @@ public class NoteService(
|
|||
? data.Reply?.UserId
|
||||
: data.Reply.MastoReplyUserId ?? data.Reply.ReplyUserId ?? data.Reply.UserId;
|
||||
|
||||
if (data.Emoji == null && data.User.IsLocalUser && nodes != null)
|
||||
if (data.Emoji == null && data.User.IsLocalUser && data.ParsedText != null)
|
||||
{
|
||||
data.Emoji = (await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList();
|
||||
data.Emoji = (await emojiSvc.ResolveEmojiAsync(data.ParsedText)).Select(p => p.Id).ToList();
|
||||
}
|
||||
|
||||
List<string> visibleUserIds = [];
|
||||
|
@ -299,8 +301,8 @@ public class NoteService(
|
|||
Id = noteId,
|
||||
Uri = data.Uri,
|
||||
Url = data.Url,
|
||||
Text = data.Text?.Trim(),
|
||||
Cw = data.Cw?.Trim(),
|
||||
Text = data.ParsedText?.Serialize(),
|
||||
Cw = data.Cw,
|
||||
Reply = data.Reply,
|
||||
ReplyUserId = data.Reply?.UserId,
|
||||
MastoReplyUserId = mastoReplyUserId,
|
||||
|
@ -485,7 +487,7 @@ public class NoteService(
|
|||
}
|
||||
}
|
||||
|
||||
private List<string> GetInlineMediaUrls(IEnumerable<IMfmNode> mfm)
|
||||
private List<string> GetInlineMediaUrls(Span<IMfmNode> mfm)
|
||||
{
|
||||
List<string> urls = [];
|
||||
|
||||
|
@ -503,7 +505,6 @@ public class NoteService(
|
|||
return urls;
|
||||
}
|
||||
|
||||
|
||||
public async Task<Note> UpdateNoteAsync(NoteUpdateData data)
|
||||
{
|
||||
logger.LogDebug("Processing note update for note {id}", data.Note.Id);
|
||||
|
@ -529,6 +530,7 @@ public class NoteService(
|
|||
FileIds = note.FileIds
|
||||
};
|
||||
|
||||
data.ParsedText = data.Text != null ? MfmParser.Parse(data.Text.ReplaceLineEndings("\n")) : null;
|
||||
policySvc.CallRewriteHooks(data, IRewritePolicy.HookLocationEnum.PreLogic);
|
||||
|
||||
var previousMentionedLocalUserIds = await db.Users.Where(p => note.Mentions.Contains(p.Id) && p.IsLocalUser)
|
||||
|
@ -540,20 +542,19 @@ public class NoteService(
|
|||
.ToListAsync();
|
||||
|
||||
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
|
||||
data.ResolvedMentions ?? await ResolveNoteMentionsAsync(data.Text);
|
||||
data.ResolvedMentions ?? await ResolveNoteMentionsAsync(data.ParsedText);
|
||||
|
||||
IMfmNode[]? nodes = null;
|
||||
if (data.Text != null && string.IsNullOrWhiteSpace(data.Text))
|
||||
{
|
||||
data.Text = null;
|
||||
data.Text = null;
|
||||
data.ParsedText = null;
|
||||
}
|
||||
else if (data.Text != null)
|
||||
{
|
||||
nodes = MfmParser.Parse(data.Text);
|
||||
mentionsResolver.ResolveMentions(nodes.AsSpan(), note.User.Host, mentions, splitDomainMapping);
|
||||
data.Text = nodes.Serialize();
|
||||
mentionsResolver.ResolveMentions(data.ParsedText, note.User.Host, mentions, splitDomainMapping);
|
||||
}
|
||||
|
||||
data.Cw = data.Cw?.Trim();
|
||||
if (data.Cw != null && string.IsNullOrWhiteSpace(data.Cw))
|
||||
data.Cw = null;
|
||||
|
||||
|
@ -571,14 +572,10 @@ public class NoteService(
|
|||
// ReSharper restore EntityFramework.UnsupportedServerSideFunctionCall
|
||||
|
||||
mentionedLocalUserIds = mentionedLocalUserIds.Except(previousMentionedLocalUserIds).ToList();
|
||||
note.Text = data.Text?.Trim();
|
||||
note.Cw = data.Cw?.Trim();
|
||||
note.Tags = ResolveHashtags(data.Text, data.ASNote);
|
||||
note.Tags = ResolveHashtags(data.ParsedText, data.ASNote);
|
||||
|
||||
if (note.User.IsLocalUser && nodes != null)
|
||||
{
|
||||
data.Emoji = (await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList();
|
||||
}
|
||||
if (note.User.IsLocalUser && data.ParsedText != null)
|
||||
data.Emoji = (await emojiSvc.ResolveEmojiAsync(data.ParsedText)).Select(p => p.Id).ToList();
|
||||
|
||||
if (data.Emoji != null && !note.Emojis.IsEquivalent(data.Emoji))
|
||||
note.Emojis = data.Emoji;
|
||||
|
@ -621,33 +618,34 @@ public class NoteService(
|
|||
note.VisibleUserIds.AddRange(missing);
|
||||
}
|
||||
|
||||
note.Text = mentionsResolver.ResolveMentions(data.Text, note.UserHost, mentions, splitDomainMapping);
|
||||
mentionsResolver.ResolveMentions(data.ParsedText, note.UserHost, mentions, splitDomainMapping);
|
||||
}
|
||||
|
||||
var attachments = data.Attachments?.ToList() ?? [];
|
||||
|
||||
var inlineMediaUrls = nodes != null ? GetInlineMediaUrls(nodes) : [];
|
||||
var inlineMediaUrls = data.ParsedText != null ? GetInlineMediaUrls(data.ParsedText) : [];
|
||||
var newMediaUrls = data.Attachments?.Select(p => p.Url) ?? [];
|
||||
var missingUrls = inlineMediaUrls.Except(newMediaUrls).ToArray();
|
||||
|
||||
if (missingUrls.Length > 0)
|
||||
{
|
||||
var missingAttachments = await db.DriveFiles
|
||||
.Where(p => missingUrls.Contains(p.PublicUrl ?? p.Url) && p.UserId == note.UserId)
|
||||
.Where(p => missingUrls.Contains(p.PublicUrl ?? p.Url)
|
||||
&& p.UserId == note.UserId)
|
||||
.ToArrayAsync();
|
||||
|
||||
attachments.AddRange(missingAttachments);
|
||||
}
|
||||
|
||||
//TODO: handle updated alt text et al
|
||||
var fileIds = attachments?.Select(p => p.Id).ToList() ?? [];
|
||||
var fileIds = attachments.Select(p => p.Id).ToList();
|
||||
if (!note.FileIds.IsEquivalent(fileIds))
|
||||
{
|
||||
note.FileIds = fileIds;
|
||||
note.AttachedFileTypes = attachments?.Select(p => p.Type).ToList() ?? [];
|
||||
note.AttachedFileTypes = attachments.Select(p => p.Type).ToList();
|
||||
|
||||
var combinedAltText = attachments?.Select(p => p.Comment).Where(c => c != null);
|
||||
note.CombinedAltText = combinedAltText != null ? string.Join(' ', combinedAltText) : null;
|
||||
var combinedAltText = attachments.Select(p => p.Comment).Where(c => c != null);
|
||||
note.CombinedAltText = string.Join(' ', combinedAltText);
|
||||
}
|
||||
|
||||
var isPollEdited = false;
|
||||
|
@ -722,6 +720,8 @@ public class NoteService(
|
|||
}
|
||||
}
|
||||
|
||||
note.Text = data.Text = data.ParsedText?.Serialize();
|
||||
|
||||
var isEdit = data.ASNote is not ASQuestion
|
||||
|| poll == null
|
||||
|| isPollEdited
|
||||
|
@ -985,9 +985,9 @@ public class NoteService(
|
|||
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
||||
}
|
||||
|
||||
var cw = note.Summary;
|
||||
var url = note.Url?.Link;
|
||||
var uri = note.Id;
|
||||
var cw = note.Summary;
|
||||
var url = note.Url?.Link;
|
||||
var uri = note.Id;
|
||||
|
||||
if (note is ASQuestion question)
|
||||
{
|
||||
|
@ -1074,7 +1074,7 @@ public class NoteService(
|
|||
if (text == null)
|
||||
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
||||
|
||||
var cw = note.Summary;
|
||||
var cw = note.Summary;
|
||||
|
||||
Poll? poll = null;
|
||||
|
||||
|
@ -1134,15 +1134,16 @@ public class NoteService(
|
|||
return ResolveNoteMentions(users.NotNull().ToList());
|
||||
}
|
||||
|
||||
private async Task<NoteMentionData> ResolveNoteMentionsAsync(string? text)
|
||||
private async Task<NoteMentionData> ResolveNoteMentionsAsync(IMfmNode[]? parsedText)
|
||||
{
|
||||
if (text == null)
|
||||
if (parsedText == null)
|
||||
return ResolveNoteMentions([]);
|
||||
var mentions = MfmParser.Parse(text)
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmMentionNode>()
|
||||
.DistinctBy(p => p.Acct)
|
||||
.ToArray();
|
||||
|
||||
var mentions = parsedText
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmMentionNode>()
|
||||
.DistinctBy(p => p.Acct)
|
||||
.ToArray();
|
||||
|
||||
if (mentions.Length > 100)
|
||||
throw GracefulException.UnprocessableEntity("Refusing to process note with more than 100 mentions");
|
||||
|
@ -1153,19 +1154,19 @@ public class NoteService(
|
|||
return ResolveNoteMentions(users.NotNull().ToList());
|
||||
}
|
||||
|
||||
private List<string> ResolveHashtags(string? text, ASNote? note = null)
|
||||
private List<string> ResolveHashtags(IMfmNode[]? parsedText, ASNote? note = null)
|
||||
{
|
||||
List<string> tags = [];
|
||||
|
||||
if (text != null)
|
||||
if (parsedText != null)
|
||||
{
|
||||
tags = MfmParser.Parse(text)
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmHashtagNode>()
|
||||
.Select(p => p.Hashtag.ToLowerInvariant())
|
||||
.Select(p => p.Trim('#'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
tags = parsedText
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmHashtagNode>()
|
||||
.Select(p => p.Hashtag.ToLowerInvariant())
|
||||
.Select(p => p.Trim('#'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var extracted = note?.Tags?.OfType<ASHashtag>()
|
||||
|
@ -1174,7 +1175,7 @@ public class NoteService(
|
|||
.Cast<string>()
|
||||
.Select(p => p.Trim('#'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
.ToArray();
|
||||
|
||||
if (extracted != null)
|
||||
tags.AddRange(extracted);
|
||||
|
@ -1248,8 +1249,7 @@ public class NoteService(
|
|||
.DistinctBy(p => p.Src)
|
||||
.Select(p => new ASDocument
|
||||
{
|
||||
Url = new ASLink(p.Src),
|
||||
Description = p.Alt,
|
||||
Url = new ASLink(p.Src), Description = p.Alt,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ public class UserProfileMentionsResolver(
|
|||
|
||||
if (actor.MkSummary != null)
|
||||
{
|
||||
var nodes = MfmParser.Parse(actor.MkSummary);
|
||||
var nodes = MfmParser.Parse(actor.MkSummary.ReplaceLineEndings("\n"));
|
||||
mentionNodes = EnumerateMentions(nodes);
|
||||
}
|
||||
|
||||
|
@ -84,7 +84,7 @@ public class UserProfileMentionsResolver(
|
|||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var nodes = input.SelectMany(p => MfmParser.Parse(p));
|
||||
var nodes = input.SelectMany(p => MfmParser.Parse(p.ReplaceLineEndings("\n"))).ToArray();
|
||||
var mentionNodes = EnumerateMentions(nodes);
|
||||
var users = await mentionNodes
|
||||
.DistinctBy(p => p.Acct)
|
||||
|
@ -106,7 +106,7 @@ public class UserProfileMentionsResolver(
|
|||
|
||||
[SuppressMessage("ReSharper", "ReturnTypeCanBeEnumerable.Local",
|
||||
Justification = "Roslyn inspection says this hurts performance")]
|
||||
private static List<MfmMentionNode> EnumerateMentions(IEnumerable<IMfmNode> nodes)
|
||||
private static List<MfmMentionNode> EnumerateMentions(Span<IMfmNode> nodes)
|
||||
{
|
||||
var list = new List<MfmMentionNode>();
|
||||
|
||||
|
|
|
@ -101,8 +101,8 @@ public class UserService(
|
|||
|
||||
return await db.Users
|
||||
.IncludeCommonProperties()
|
||||
.FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant() &&
|
||||
p.Host == tuple.Host);
|
||||
.FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant()
|
||||
&& p.Host == tuple.Host);
|
||||
}
|
||||
|
||||
public async Task<User> CreateUserAsync(string uri, string acct)
|
||||
|
@ -131,8 +131,8 @@ public class UserService(
|
|||
|
||||
actor.NormalizeAndValidate(uri);
|
||||
|
||||
user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == actor.Username!.ToLowerInvariant() &&
|
||||
p.Host == host);
|
||||
user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == actor.Username!.ToLowerInvariant()
|
||||
&& p.Host == host);
|
||||
if (user is not null)
|
||||
throw GracefulException
|
||||
.UnprocessableEntity($"A user with acct @{user.UsernameLower}@{user.Host} already exists: {user.Uri}");
|
||||
|
@ -155,16 +155,16 @@ public class UserService(
|
|||
.AwaitAllAsync()
|
||||
: null;
|
||||
|
||||
var bio = actor.MkSummary ?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||
|
||||
var tags = ResolveHashtags(bio, actor);
|
||||
var bio = actor.MkSummary?.ReplaceLineEndings("\n").Trim()
|
||||
?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||
var tags = ResolveHashtags(MfmParser.Parse(bio), actor);
|
||||
|
||||
user = new User
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastFetchedAt = followupTaskSvc.IsBackgroundWorker.Value ? null : DateTime.UtcNow,
|
||||
DisplayName = actor.DisplayName,
|
||||
DisplayName = actor.DisplayName?.ReplaceLineEndings("\n").Trim(),
|
||||
IsLocked = actor.IsLocked ?? false,
|
||||
IsBot = actor.IsBot,
|
||||
Username = actor.Username!,
|
||||
|
@ -253,8 +253,8 @@ public class UserService(
|
|||
|
||||
public async Task<User> UpdateUserAsync(string id)
|
||||
{
|
||||
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
|
||||
throw new Exception("Cannot update nonexistent user");
|
||||
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id)
|
||||
?? throw new Exception("Cannot update nonexistent user");
|
||||
return await UpdateUserAsync(user, force: true);
|
||||
}
|
||||
|
||||
|
@ -283,7 +283,7 @@ public class UserService(
|
|||
user.LastFetchedAt = DateTime.UtcNow; // If we don't do this we'll overwrite the value with the previous one
|
||||
user.Inbox = actor.Inbox?.Link;
|
||||
user.SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id;
|
||||
user.DisplayName = actor.DisplayName;
|
||||
user.DisplayName = actor.DisplayName?.ReplaceLineEndings("\n").Trim();
|
||||
user.IsLocked = actor.IsLocked ?? false;
|
||||
user.IsBot = actor.IsBot;
|
||||
user.MovedToUri = actor.MovedTo?.Link;
|
||||
|
@ -294,8 +294,9 @@ public class UserService(
|
|||
user.Featured = actor.Featured?.Id;
|
||||
|
||||
var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(),
|
||||
user.Host ??
|
||||
throw new Exception("User host must not be null at this stage"));
|
||||
user.Host
|
||||
?? throw new
|
||||
Exception("User host must not be null at this stage"));
|
||||
|
||||
var fields = actor.Attachments != null
|
||||
? await actor.Attachments
|
||||
|
@ -316,7 +317,8 @@ public class UserService(
|
|||
|
||||
var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
|
||||
|
||||
user.UserProfile.Description = actor.MkSummary ?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||
user.UserProfile.Description = actor.MkSummary?.ReplaceLineEndings("\n").Trim()
|
||||
?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||
//user.UserProfile.Birthday = TODO;
|
||||
//user.UserProfile.Location = TODO;
|
||||
user.UserProfile.Fields = fields?.ToArray() ?? [];
|
||||
|
@ -325,7 +327,7 @@ public class UserService(
|
|||
|
||||
user.UserProfile.MentionsResolved = false;
|
||||
|
||||
user.Tags = ResolveHashtags(user.UserProfile.Description, actor);
|
||||
user.Tags = ResolveHashtags(MfmParser.Parse(user.UserProfile.Description), actor);
|
||||
user.Host = await UpdateUserHostAsync(user);
|
||||
|
||||
db.Update(user);
|
||||
|
@ -341,24 +343,25 @@ public class UserService(
|
|||
if (user.IsRemoteUser) throw new Exception("This method is only valid for local users");
|
||||
if (user.UserProfile == null) throw new Exception("user.UserProfile must not be null at this stage");
|
||||
|
||||
user.Tags = ResolveHashtags(user.UserProfile.Description);
|
||||
user.DisplayName = user.DisplayName?.ReplaceLineEndings("\n").Trim();
|
||||
user.UserProfile.Description = user.UserProfile.Description?.ReplaceLineEndings("\n").Trim();
|
||||
|
||||
var parsedName = user.DisplayName != null ? MfmParser.Parse(user.DisplayName) : null;
|
||||
var parsedBio = user.UserProfile.Description != null ? MfmParser.Parse(user.UserProfile.Description) : null;
|
||||
|
||||
user.Tags = parsedBio != null ? ResolveHashtags(parsedBio) : [];
|
||||
user.Emojis = [];
|
||||
|
||||
if (user.UserProfile.Description != null)
|
||||
{
|
||||
var nodes = MfmParser.Parse(user.UserProfile.Description);
|
||||
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList());
|
||||
}
|
||||
if (parsedBio != null)
|
||||
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedBio)).Select(p => p.Id).ToList());
|
||||
if (parsedName != null)
|
||||
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedName)).Select(p => p.Id).ToList());
|
||||
|
||||
if (user.DisplayName != null)
|
||||
if (user.UserProfile.Fields is { Length: > 0 } fields)
|
||||
{
|
||||
var nodes = MfmParser.Parse(user.DisplayName);
|
||||
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList());
|
||||
}
|
||||
foreach (var field in fields)
|
||||
field.Name = field.Name.ReplaceLineEndings("\n").Trim();
|
||||
|
||||
if (user.UserProfile.Fields.Length != 0)
|
||||
{
|
||||
var input = user.UserProfile.Fields.Select(p => $"{p.Name} {p.Value}");
|
||||
var nodes = MfmParser.Parse(string.Join('\n', input));
|
||||
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList());
|
||||
|
@ -392,8 +395,8 @@ public class UserService(
|
|||
throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
|
||||
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null)
|
||||
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code");
|
||||
if (security.Value.Registrations == Enums.Registrations.Invite &&
|
||||
!await db.RegistrationInvites.AnyAsync(p => p.Code == invite))
|
||||
if (security.Value.Registrations == Enums.Registrations.Invite
|
||||
&& !await db.RegistrationInvites.AnyAsync(p => p.Code == invite))
|
||||
throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
|
||||
if (!Regex.IsMatch(username, @"^\w+$"))
|
||||
throw new GracefulException(HttpStatusCode.BadRequest, "Username must only contain letters and numbers");
|
||||
|
@ -638,9 +641,9 @@ public class UserService(
|
|||
|
||||
// Clean up notifications
|
||||
await db.Notifications
|
||||
.Where(p => p.Type == Notification.NotificationType.FollowRequestReceived &&
|
||||
p.Notifiee == request.Followee &&
|
||||
p.Notifier == request.Follower)
|
||||
.Where(p => p.Type == Notification.NotificationType.FollowRequestReceived
|
||||
&& p.Notifiee == request.Followee
|
||||
&& p.Notifier == request.Follower)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
|
@ -658,13 +661,13 @@ public class UserService(
|
|||
|
||||
// Clean up notifications
|
||||
await db.Notifications
|
||||
.Where(p => ((p.Type == Notification.NotificationType.FollowRequestReceived ||
|
||||
p.Type == Notification.NotificationType.Follow) &&
|
||||
p.Notifiee == request.Followee &&
|
||||
p.Notifier == request.Follower) ||
|
||||
(p.Type == Notification.NotificationType.FollowRequestAccepted &&
|
||||
p.Notifiee == request.Follower &&
|
||||
p.Notifier == request.Followee))
|
||||
.Where(p => ((p.Type == Notification.NotificationType.FollowRequestReceived
|
||||
|| p.Type == Notification.NotificationType.Follow)
|
||||
&& p.Notifiee == request.Followee
|
||||
&& p.Notifier == request.Follower)
|
||||
|| (p.Type == Notification.NotificationType.FollowRequestAccepted
|
||||
&& p.Notifiee == request.Follower
|
||||
&& p.Notifier == request.Followee))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
|
@ -734,11 +737,12 @@ public class UserService(
|
|||
// Otherwise, create a new request and insert it into the database
|
||||
else
|
||||
{
|
||||
var autoAccept = followee.IsLocalUser &&
|
||||
await db.Followings.AnyAsync(p => p.Follower == followee &&
|
||||
p.Followee == follower &&
|
||||
p.Follower.UserSettings != null &&
|
||||
p.Follower.UserSettings.AutoAcceptFollowed);
|
||||
var autoAccept = followee.IsLocalUser
|
||||
&& await db.Followings.AnyAsync(p => p.Follower == followee
|
||||
&& p.Followee == follower
|
||||
&& p.Follower.UserSettings != null
|
||||
&& p.Follower.UserSettings
|
||||
.AutoAcceptFollowed);
|
||||
|
||||
// Followee has auto accept enabled & is already following the follower user
|
||||
if (autoAccept)
|
||||
|
@ -926,15 +930,15 @@ public class UserService(
|
|||
/// </remarks>
|
||||
public async Task UnfollowUserAsync(User user, User followee)
|
||||
{
|
||||
if (((followee.PrecomputedIsFollowedBy ?? false) || (followee.PrecomputedIsRequestedBy ?? false)) &&
|
||||
followee.IsRemoteUser)
|
||||
if (((followee.PrecomputedIsFollowedBy ?? false) || (followee.PrecomputedIsRequestedBy ?? false))
|
||||
&& followee.IsRemoteUser)
|
||||
{
|
||||
var relationshipId = await db.Followings.Where(p => p.Follower == user && p.Followee == followee)
|
||||
.Select(p => p.RelationshipId)
|
||||
.FirstOrDefaultAsync() ??
|
||||
await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee)
|
||||
.Select(p => p.RelationshipId)
|
||||
.FirstOrDefaultAsync();
|
||||
.FirstOrDefaultAsync()
|
||||
?? await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee)
|
||||
.Select(p => p.RelationshipId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var activity = activityRenderer.RenderUnfollow(user, followee, relationshipId);
|
||||
await deliverSvc.DeliverToAsync(activity, user, followee);
|
||||
|
@ -980,12 +984,12 @@ public class UserService(
|
|||
|
||||
// Clean up notifications
|
||||
await db.Notifications
|
||||
.Where(p => (p.Type == Notification.NotificationType.FollowRequestAccepted &&
|
||||
p.Notifiee == user &&
|
||||
p.Notifier == followee) ||
|
||||
(p.Type == Notification.NotificationType.Follow &&
|
||||
p.Notifiee == followee &&
|
||||
p.Notifier == user))
|
||||
.Where(p => (p.Type == Notification.NotificationType.FollowRequestAccepted
|
||||
&& p.Notifiee == user
|
||||
&& p.Notifier == followee)
|
||||
|| (p.Type == Notification.NotificationType.Follow
|
||||
&& p.Notifiee == followee
|
||||
&& p.Notifier == user))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Clean up user list memberships
|
||||
|
@ -1103,19 +1107,19 @@ public class UserService(
|
|||
return user;
|
||||
}
|
||||
|
||||
private List<string> ResolveHashtags(string? text, ASActor? actor = null)
|
||||
private List<string> ResolveHashtags(IMfmNode[]? parsedText, ASActor? actor = null)
|
||||
{
|
||||
List<string> tags = [];
|
||||
|
||||
if (text != null)
|
||||
if (parsedText != null)
|
||||
{
|
||||
tags = MfmParser.Parse(text)
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmHashtagNode>()
|
||||
.Select(p => p.Hashtag.ToLowerInvariant())
|
||||
.Select(p => p.Trim('#'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
tags = parsedText
|
||||
.SelectMany(p => p.Children.Append(p))
|
||||
.OfType<MfmHashtagNode>()
|
||||
.Select(p => p.Hashtag.ToLowerInvariant())
|
||||
.Select(p => p.Trim('#'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var extracted = actor?.Tags?.OfType<ASHashtag>()
|
||||
|
@ -1220,13 +1224,12 @@ public class UserService(
|
|||
.SetProperty(i => i.FollowingCount, i => i.FollowingCount - cnt1));
|
||||
|
||||
// clean up notifications
|
||||
await db.Notifications.Where(p => ((p.Notifiee == blocker &&
|
||||
p.Notifier == blockee) ||
|
||||
(p.Notifiee == blockee &&
|
||||
p.Notifier == blocker)) &&
|
||||
(p.Type == Notification.NotificationType.Follow ||
|
||||
p.Type == Notification.NotificationType.FollowRequestAccepted ||
|
||||
p.Type == Notification.NotificationType.FollowRequestReceived))
|
||||
await db.Notifications
|
||||
.Where(p => ((p.Notifiee == blocker && p.Notifier == blockee)
|
||||
|| (p.Notifiee == blockee && p.Notifier == blocker))
|
||||
&& (p.Type == Notification.NotificationType.Follow
|
||||
|| p.Type == Notification.NotificationType.FollowRequestAccepted
|
||||
|| p.Type == Notification.NotificationType.FollowRequestReceived))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await db.AddAsync(blocking);
|
||||
|
@ -1439,4 +1442,4 @@ public class UserService(
|
|||
user.IsSuspended = false;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
<PackageReference Include="Ulid" Version="1.3.4" />
|
||||
<PackageReference Include="Iceshrimp.Assets.Branding" Version="1.0.1" />
|
||||
<PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.3" />
|
||||
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.3" />
|
||||
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.4" />
|
||||
<PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.1" />
|
||||
<PackageReference Include="Iceshrimp.WebPush" Version="2.1.0" />
|
||||
<PackageReference Include="NetVips" Version="3.0.0" />
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
<PackageReference Include="BlazorIntersectionObserver" Version="3.1.0" />
|
||||
<PackageReference Include="Iceshrimp.Assets.Branding" Version="1.0.1" />
|
||||
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.3" />
|
||||
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
|
||||
|
|
Loading…
Add table
Reference in a new issue