[backend] Implement inline media
Inline media can be created by: 1. Attach media to note as usual 2. Copy media URL (public one, for remote instances) 3. Use the new $[media url ] MFM extension to place it wherever you wish. (The trailing space is necessary as the parser currently treats the closing ] as a part of the URL) The Iceshrimp frontend may make this easier later on (by having a "copy inline MFM" button on attachments, maybe?) Federates as <img>, <video>, <audio>, or <a download> HTML tags depending on the media type for interoperability. (<a download> is not handled for incoming media yet). The media will also be present in the attachments field, both as a fallback for instance software that do not support inline media, but also for MFM federation to discover which media it is allowed to embed (and metadata like alt text and sensitive-ness). This is not required for remote instances sending inline media, as it will be extracted out from the HTML. The Iceshrimp frontend does not render inline media yet. That is blocked on #67.
This commit is contained in:
parent
3edda1e70e
commit
89060599eb
10 changed files with 246 additions and 90 deletions
|
@ -18,7 +18,7 @@ public class MfmRenderer(MfmConverter converter) : ISingletonService
|
||||||
// Ensure we are rendering HTML markup (AsyncLocal)
|
// Ensure we are rendering HTML markup (AsyncLocal)
|
||||||
converter.SupportsHtmlFormatting.Value = true;
|
converter.SupportsHtmlFormatting.Value = true;
|
||||||
|
|
||||||
var serialized = await converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement);
|
var serialized = (await converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement)).Html;
|
||||||
return new MarkupString(serialized);
|
return new MarkupString(serialized);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -55,7 +55,7 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
await res.Select(async p => p.Content = await mfmConverter.ToHtmlAsync(p.Content, [], null)).AwaitAllAsync();
|
await res.Select(async p => p.Content = (await mfmConverter.ToHtmlAsync(p.Content, [], null)).Html).AwaitAllAsync();
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,9 @@ public class NoteRenderer(
|
||||||
? await GetAttachmentsAsync([note])
|
? await GetAttachmentsAsync([note])
|
||||||
: [..data.Attachments.Where(p => note.FileIds.Contains(p.Id))];
|
: [..data.Attachments.Where(p => note.FileIds.Contains(p.Id))];
|
||||||
|
|
||||||
|
if (user == null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
|
||||||
|
attachments = [];
|
||||||
|
|
||||||
var reactions = data?.Reactions == null
|
var reactions = data?.Reactions == null
|
||||||
? await GetReactionsAsync([note], user)
|
? await GetReactionsAsync([note], user)
|
||||||
: [..data.Reactions.Where(p => p.NoteId == note.Id)];
|
: [..data.Reactions.Where(p => p.NoteId == note.Id)];
|
||||||
|
@ -99,12 +102,31 @@ public class NoteRenderer(
|
||||||
var quoteInaccessible =
|
var quoteInaccessible =
|
||||||
note.Renote == null && ((note.RenoteId != null && recurse > 0) || note.RenoteUri != null);
|
note.Renote == null && ((note.RenoteId != null && recurse > 0) || note.RenoteUri != null);
|
||||||
|
|
||||||
var content = data?.Source != true
|
var sensitive = note.Cw != null || attachments.Any(p => p.Sensitive);
|
||||||
? text != null || quoteUri != null || quoteInaccessible || replyInaccessible
|
|
||||||
? await mfmConverter.ToHtmlAsync(text ?? "", mentionedUsers, note.UserHost, quoteUri,
|
// TODO: canDisplayInlineMedia oauth_token flag that marks all media as "other" so they end up as links
|
||||||
quoteInaccessible, replyInaccessible)
|
// (and doesn't remove the attachments)
|
||||||
: ""
|
var inlineMedia = attachments.Select(p => new MfmInlineMedia(p.Type switch
|
||||||
: null;
|
{
|
||||||
|
AttachmentType.Audio => MfmInlineMedia.MediaType.Audio,
|
||||||
|
AttachmentType.Video => MfmInlineMedia.MediaType.Video,
|
||||||
|
AttachmentType.Image or AttachmentType.Gif => MfmInlineMedia.MediaType.Image,
|
||||||
|
_ => MfmInlineMedia.MediaType.Other
|
||||||
|
}, p.RemoteUrl ?? p.Url, p.Description)).ToList();
|
||||||
|
|
||||||
|
string? content = null;
|
||||||
|
if (data?.Source != true)
|
||||||
|
if (text != null || quoteUri != null || quoteInaccessible || replyInaccessible)
|
||||||
|
{
|
||||||
|
(content, inlineMedia) = await mfmConverter.ToHtmlAsync(text ?? "", mentionedUsers, note.UserHost, quoteUri,
|
||||||
|
quoteInaccessible, replyInaccessible, media: inlineMedia);
|
||||||
|
|
||||||
|
attachments.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content = "";
|
||||||
|
}
|
||||||
|
|
||||||
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
|
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
|
||||||
await userRenderer.RenderAsync(note.User, user);
|
await userRenderer.RenderAsync(note.User, user);
|
||||||
|
@ -130,9 +152,6 @@ public class NoteRenderer(
|
||||||
if ((user?.UserSettings?.FilterInaccessible ?? false) && (replyInaccessible || quoteInaccessible))
|
if ((user?.UserSettings?.FilterInaccessible ?? false) && (replyInaccessible || quoteInaccessible))
|
||||||
filterResult.Insert(0, InaccessibleFilter);
|
filterResult.Insert(0, InaccessibleFilter);
|
||||||
|
|
||||||
if (user == null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
|
|
||||||
attachments = [];
|
|
||||||
|
|
||||||
var res = new StatusEntity
|
var res = new StatusEntity
|
||||||
{
|
{
|
||||||
Id = note.Id,
|
Id = note.Id,
|
||||||
|
@ -155,7 +174,7 @@ public class NoteRenderer(
|
||||||
IsRenoted = renoted,
|
IsRenoted = renoted,
|
||||||
IsBookmarked = bookmarked,
|
IsBookmarked = bookmarked,
|
||||||
IsMuted = muted,
|
IsMuted = muted,
|
||||||
IsSensitive = note.Cw != null || attachments.Any(p => p.Sensitive),
|
IsSensitive = sensitive,
|
||||||
ContentWarning = note.Cw ?? "",
|
ContentWarning = note.Cw ?? "",
|
||||||
Visibility = StatusEntity.EncodeVisibility(note.Visibility),
|
Visibility = StatusEntity.EncodeVisibility(note.Visibility),
|
||||||
Content = content,
|
Content = content,
|
||||||
|
@ -196,10 +215,24 @@ public class NoteRenderer(
|
||||||
foreach (var edit in edits)
|
foreach (var edit in edits)
|
||||||
{
|
{
|
||||||
var files = attachments.Where(p => edit.FileIds.Contains(p.Id)).ToList();
|
var files = attachments.Where(p => edit.FileIds.Contains(p.Id)).ToList();
|
||||||
|
|
||||||
|
// TODO: canDisplayInlineMedia oauth_token flag that marks all media as "other" so they end up as links
|
||||||
|
// (and doesn't remove the attachments)
|
||||||
|
var inlineMedia = files.Select(p => new MfmInlineMedia(p.Type switch
|
||||||
|
{
|
||||||
|
AttachmentType.Audio => MfmInlineMedia.MediaType.Audio,
|
||||||
|
AttachmentType.Video => MfmInlineMedia.MediaType.Video,
|
||||||
|
AttachmentType.Image or AttachmentType.Gif => MfmInlineMedia.MediaType.Image,
|
||||||
|
_ => MfmInlineMedia.MediaType.Other
|
||||||
|
}, p.RemoteUrl ?? p.Url, p.Description)).ToList();
|
||||||
|
|
||||||
|
(var content, inlineMedia) = await mfmConverter.ToHtmlAsync(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia);
|
||||||
|
files.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
|
||||||
|
|
||||||
var entry = new StatusEdit
|
var entry = new StatusEdit
|
||||||
{
|
{
|
||||||
Account = account,
|
Account = account,
|
||||||
Content = await mfmConverter.ToHtmlAsync(edit.Text ?? "", mentionedUsers, note.UserHost),
|
Content = content,
|
||||||
CreatedAt = lastDate.ToStringIso8601Like(),
|
CreatedAt = lastDate.ToStringIso8601Like(),
|
||||||
Emojis = [],
|
Emojis = [],
|
||||||
IsSensitive = files.Any(p => p.Sensitive),
|
IsSensitive = files.Any(p => p.Sensitive),
|
||||||
|
|
|
@ -33,7 +33,7 @@ public class UserRenderer(
|
||||||
.Select(async p => new Field
|
.Select(async p => new Field
|
||||||
{
|
{
|
||||||
Name = p.Name,
|
Name = p.Name,
|
||||||
Value = await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host),
|
Value = (await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host)).Html,
|
||||||
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
|
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
|
||||||
? DateTime.Now.ToStringIso8601Like()
|
? DateTime.Now.ToStringIso8601Like()
|
||||||
: null
|
: null
|
||||||
|
@ -58,7 +58,7 @@ public class UserRenderer(
|
||||||
FollowersCount = user.FollowersCount,
|
FollowersCount = user.FollowersCount,
|
||||||
FollowingCount = user.FollowingCount,
|
FollowingCount = user.FollowingCount,
|
||||||
StatusesCount = user.NotesCount,
|
StatusesCount = user.NotesCount,
|
||||||
Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host),
|
Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html,
|
||||||
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
|
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
|
||||||
Uri = user.Uri ?? user.GetPublicUri(config.Value),
|
Uri = user.Uri ?? user.GetPublicUri(config.Value),
|
||||||
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO
|
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO
|
||||||
|
|
|
@ -109,10 +109,14 @@ public class NoteRenderer(
|
||||||
}))
|
}))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var attachments = note.FileIds.Count > 0
|
var driveFiles = note.FileIds.Count > 0
|
||||||
? await db.DriveFiles
|
? await db.DriveFiles
|
||||||
.Where(p => note.FileIds.Contains(p.Id) && p.UserHost == null)
|
.Where(p => note.FileIds.Contains(p.Id) && p.UserHost == null)
|
||||||
.Select(p => new ASDocument
|
.ToListAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var sensitive = note.Cw != null || (driveFiles?.Any(p => p.IsSensitive) ?? false);
|
||||||
|
var attachments = driveFiles?.Select(p => new ASDocument
|
||||||
{
|
{
|
||||||
Sensitive = p.IsSensitive,
|
Sensitive = p.IsSensitive,
|
||||||
Url = new ASLink(p.AccessUrl),
|
Url = new ASLink(p.AccessUrl),
|
||||||
|
@ -120,8 +124,10 @@ public class NoteRenderer(
|
||||||
Description = p.Comment
|
Description = p.Comment
|
||||||
})
|
})
|
||||||
.Cast<ASAttachment>()
|
.Cast<ASAttachment>()
|
||||||
.ToListAsync()
|
.ToList();
|
||||||
: null;
|
|
||||||
|
var inlineMedia = driveFiles?.Select(p => new MfmInlineMedia(MfmInlineMedia.GetType(p.Type), p.AccessUrl, p.Comment))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var quoteUri = note.IsQuote ? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(config.Value) : null;
|
var quoteUri = note.IsQuote ? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(config.Value) : null;
|
||||||
var text = quoteUri != null ? note.Text + $"\n\nRE: {quoteUri}" : note.Text;
|
var text = quoteUri != null ? note.Text + $"\n\nRE: {quoteUri}" : note.Text;
|
||||||
|
@ -137,8 +143,6 @@ public class NoteRenderer(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensitive = note.Cw != null || (attachments?.OfType<ASDocument>().Any(p => p.Sensitive == true) ?? false);
|
|
||||||
|
|
||||||
if (note.HasPoll)
|
if (note.HasPoll)
|
||||||
{
|
{
|
||||||
var poll = await db.Polls.FirstOrDefaultAsync(p => p.Note == note);
|
var poll = await db.Polls.FirstOrDefaultAsync(p => p.Note == note);
|
||||||
|
@ -177,7 +181,7 @@ public class NoteRenderer(
|
||||||
To = to,
|
To = to,
|
||||||
Tags = tags,
|
Tags = tags,
|
||||||
Attachments = attachments,
|
Attachments = attachments,
|
||||||
Content = text != null ? await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost) : null,
|
Content = text != null ? (await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost, media: inlineMedia)).Html : null,
|
||||||
Summary = note.Cw,
|
Summary = note.Cw,
|
||||||
Source = note.Text != null
|
Source = note.Text != null
|
||||||
? new ASNoteSource { Content = note.Text, MediaType = "text/x.misskeymarkdown" }
|
? new ASNoteSource { Content = note.Text, MediaType = "text/x.misskeymarkdown" }
|
||||||
|
@ -209,7 +213,7 @@ public class NoteRenderer(
|
||||||
To = to,
|
To = to,
|
||||||
Tags = tags,
|
Tags = tags,
|
||||||
Attachments = attachments,
|
Attachments = attachments,
|
||||||
Content = text != null ? await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost) : null,
|
Content = text != null ? (await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost, media: inlineMedia)).Html : null,
|
||||||
Summary = note.Cw,
|
Summary = note.Cw,
|
||||||
Source = note.Text != null
|
Source = note.Text != null
|
||||||
? new ASNoteSource { Content = note.Text, MediaType = "text/x.misskeymarkdown" }
|
? new ASNoteSource { Content = note.Text, MediaType = "text/x.misskeymarkdown" }
|
||||||
|
|
|
@ -71,7 +71,7 @@ public class UserRenderer(
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var summary = profile?.Description != null
|
var summary = profile?.Description != null
|
||||||
? await mfmConverter.ToHtmlAsync(profile.Description, profile.Mentions, user.Host)
|
? (await mfmConverter.ToHtmlAsync(profile.Description, profile.Mentions, user.Host)).Html
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return new ASActor
|
return new ASActor
|
||||||
|
|
|
@ -14,15 +14,36 @@ using HtmlParser = AngleSharp.Html.Parser.HtmlParser;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
|
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
|
||||||
|
|
||||||
|
public record MfmInlineMedia(MfmInlineMedia.MediaType Type, string Src, string? Alt)
|
||||||
|
{
|
||||||
|
public enum MediaType
|
||||||
|
{
|
||||||
|
Other,
|
||||||
|
Image,
|
||||||
|
Video,
|
||||||
|
Audio
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MediaType GetType(string mime)
|
||||||
|
{
|
||||||
|
if (mime.StartsWith("image/")) return MediaType.Image;
|
||||||
|
if (mime.StartsWith("video/")) return MediaType.Video;
|
||||||
|
if (mime.StartsWith("audio/")) return MediaType.Audio;
|
||||||
|
|
||||||
|
return MediaType.Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class MfmConverter(
|
public class MfmConverter(
|
||||||
IOptions<Config.InstanceSection> config
|
IOptions<Config.InstanceSection> config
|
||||||
) : ISingletonService
|
) : ISingletonService
|
||||||
{
|
{
|
||||||
public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new();
|
public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new();
|
||||||
|
|
||||||
public static async Task<string?> FromHtmlAsync(string? html, List<Note.MentionedUser>? mentions = null)
|
public static async Task<(string Mfm, List<MfmInlineMedia> InlineMedia)> FromHtmlAsync(string? html, List<Note.MentionedUser>? mentions = null)
|
||||||
{
|
{
|
||||||
if (html == null) return null;
|
var media = new List<MfmInlineMedia>();
|
||||||
|
if (html == null) return ("", media);
|
||||||
|
|
||||||
// Ensure compatibility with AP servers that send both <br> as well as newlines
|
// Ensure compatibility with AP servers that send both <br> as well as newlines
|
||||||
var regex = new Regex(@"<br\s?\/?>\r?\n", RegexOptions.IgnoreCase);
|
var regex = new Regex(@"<br\s?\/?>\r?\n", RegexOptions.IgnoreCase);
|
||||||
|
@ -32,12 +53,12 @@ public class MfmConverter(
|
||||||
html = html.Replace("\u00A0", " ");
|
html = html.Replace("\u00A0", " ");
|
||||||
|
|
||||||
var dom = await new HtmlParser().ParseDocumentAsync(html);
|
var dom = await new HtmlParser().ParseDocumentAsync(html);
|
||||||
if (dom.Body == null) return "";
|
if (dom.Body == null) return ("", media);
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var parser = new MfmHtmlParser(mentions ?? []);
|
var parser = new MfmHtmlParser(mentions ?? [], media);
|
||||||
dom.Body.ChildNodes.Select(parser.ParseNode).ToList().ForEach(s => sb.Append(s));
|
dom.Body.ChildNodes.Select(parser.ParseNode).ToList().ForEach(s => sb.Append(s));
|
||||||
return sb.ToString().Trim();
|
return (sb.ToString().Trim(), media);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<List<string>> ExtractMentionsFromHtmlAsync(string? html)
|
public static async Task<List<string>> ExtractMentionsFromHtmlAsync(string? html)
|
||||||
|
@ -58,10 +79,10 @@ public class MfmConverter(
|
||||||
return parser.Mentions;
|
return parser.Mentions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> ToHtmlAsync(
|
public async Task<(string Html, List<MfmInlineMedia> InlineMedia)> ToHtmlAsync(
|
||||||
IEnumerable<IMfmNode> nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
IEnumerable<IMfmNode> nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
||||||
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
|
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
|
||||||
List<Emoji>? emoji = null
|
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var context = BrowsingContext.New();
|
var context = BrowsingContext.New();
|
||||||
|
@ -86,7 +107,8 @@ public class MfmConverter(
|
||||||
element.AppendChild(wrapper);
|
element.AppendChild(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var node in nodeList) element.AppendNodes(FromMfmNode(document, node, mentions, host, emoji));
|
var usedMedia = new List<MfmInlineMedia>();
|
||||||
|
foreach (var node in nodeList) element.AppendNodes(FromMfmNode(document, node, mentions, host, ref usedMedia, emoji, media));
|
||||||
|
|
||||||
if (quoteUri != null)
|
if (quoteUri != null)
|
||||||
{
|
{
|
||||||
|
@ -126,45 +148,87 @@ public class MfmConverter(
|
||||||
|
|
||||||
await using var sw = new StringWriter();
|
await using var sw = new StringWriter();
|
||||||
await element.ToHtmlAsync(sw);
|
await element.ToHtmlAsync(sw);
|
||||||
return sw.ToString();
|
return (sw.ToString(), usedMedia);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> ToHtmlAsync(
|
public async Task<(string Html, List<MfmInlineMedia> InlineMedia)> ToHtmlAsync(
|
||||||
string mfm, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
string mfm, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
|
||||||
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
|
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
|
||||||
List<Emoji>? emoji = null
|
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var nodes = MfmParser.Parse(mfm);
|
var nodes = MfmParser.Parse(mfm);
|
||||||
return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible,
|
return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible,
|
||||||
replyInaccessible, rootElement, emoji);
|
replyInaccessible, rootElement, emoji, media);
|
||||||
}
|
}
|
||||||
|
|
||||||
private INode FromMfmNode(
|
private INode FromMfmNode(
|
||||||
IDocument document, IMfmNode node, List<Note.MentionedUser> mentions, string? host, List<Emoji>? emoji = null
|
IDocument document, IMfmNode node, List<Note.MentionedUser> mentions, string? host, ref List<MfmInlineMedia> usedMedia,
|
||||||
|
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
switch (node)
|
switch (node)
|
||||||
{
|
{
|
||||||
|
case MfmFnNode { Name: "media" } fn when media != null:
|
||||||
|
{
|
||||||
|
var urlNode = fn.Children.FirstOrDefault();
|
||||||
|
if (urlNode is MfmUrlNode url)
|
||||||
|
{
|
||||||
|
var current = media.FirstOrDefault(m => m.Src == url.Url);
|
||||||
|
if (current != null)
|
||||||
|
{
|
||||||
|
var nodeName = current.Type switch
|
||||||
|
{
|
||||||
|
MfmInlineMedia.MediaType.Image => "img",
|
||||||
|
MfmInlineMedia.MediaType.Video => "video",
|
||||||
|
MfmInlineMedia.MediaType.Audio => "audio",
|
||||||
|
_ => "a",
|
||||||
|
};
|
||||||
|
var el = document.CreateElement(nodeName);
|
||||||
|
if (current.Type == MfmInlineMedia.MediaType.Other)
|
||||||
|
{
|
||||||
|
el.SetAttribute("href", current.Src);
|
||||||
|
el.SetAttribute("download", "true");
|
||||||
|
el.TextContent = $"\ud83d\udcbe {current.Alt ?? current.Src}"; // floppy disk emoji
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
el.SetAttribute("src", current.Src);
|
||||||
|
el.SetAttribute("alt", current.Alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
usedMedia.Add(current);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var el = CreateInlineFormattingElement(document, "i");
|
||||||
|
AddHtmlMarkup(document, el, "*");
|
||||||
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
|
AddHtmlMarkup(document, el, "*");
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
case MfmBoldNode:
|
case MfmBoldNode:
|
||||||
{
|
{
|
||||||
var el = CreateInlineFormattingElement(document, "b");
|
var el = CreateInlineFormattingElement(document, "b");
|
||||||
AddHtmlMarkup(document, el, "**");
|
AddHtmlMarkup(document, el, "**");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
AddHtmlMarkup(document, el, "**");
|
AddHtmlMarkup(document, el, "**");
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
case MfmSmallNode:
|
case MfmSmallNode:
|
||||||
{
|
{
|
||||||
var el = document.CreateElement("small");
|
var el = document.CreateElement("small");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
case MfmStrikeNode:
|
case MfmStrikeNode:
|
||||||
{
|
{
|
||||||
var el = CreateInlineFormattingElement(document, "del");
|
var el = CreateInlineFormattingElement(document, "del");
|
||||||
AddHtmlMarkup(document, el, "~~");
|
AddHtmlMarkup(document, el, "~~");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
AddHtmlMarkup(document, el, "~~");
|
AddHtmlMarkup(document, el, "~~");
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
@ -173,7 +237,7 @@ public class MfmConverter(
|
||||||
{
|
{
|
||||||
var el = CreateInlineFormattingElement(document, "i");
|
var el = CreateInlineFormattingElement(document, "i");
|
||||||
AddHtmlMarkup(document, el, "*");
|
AddHtmlMarkup(document, el, "*");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
AddHtmlMarkup(document, el, "*");
|
AddHtmlMarkup(document, el, "*");
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
@ -188,7 +252,7 @@ public class MfmConverter(
|
||||||
case MfmCenterNode:
|
case MfmCenterNode:
|
||||||
{
|
{
|
||||||
var el = document.CreateElement("div");
|
var el = document.CreateElement("div");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
case MfmEmojiCodeNode emojiCodeNode:
|
case MfmEmojiCodeNode emojiCodeNode:
|
||||||
|
@ -278,7 +342,7 @@ public class MfmConverter(
|
||||||
{
|
{
|
||||||
var el = CreateInlineFormattingElement(document, "blockquote");
|
var el = CreateInlineFormattingElement(document, "blockquote");
|
||||||
AddHtmlMarkup(document, el, "> ");
|
AddHtmlMarkup(document, el, "> ");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
el.AppendChild(document.CreateElement("br"));
|
el.AppendChild(document.CreateElement("br"));
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
@ -312,7 +376,7 @@ public class MfmConverter(
|
||||||
case MfmPlainNode:
|
case MfmPlainNode:
|
||||||
{
|
{
|
||||||
var el = document.CreateElement("span");
|
var el = document.CreateElement("span");
|
||||||
AppendChildren(el, document, node, mentions, host);
|
AppendChildren(el, document, node, mentions, host, ref usedMedia);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -324,10 +388,11 @@ public class MfmConverter(
|
||||||
|
|
||||||
private void AppendChildren(
|
private void AppendChildren(
|
||||||
INode element, IDocument document, IMfmNode parent,
|
INode element, IDocument document, IMfmNode parent,
|
||||||
List<Note.MentionedUser> mentions, string? host, List<Emoji>? emoji = null
|
List<Note.MentionedUser> mentions, string? host, ref List<MfmInlineMedia> usedMedia,
|
||||||
|
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions, host, emoji));
|
foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions, host, ref usedMedia, emoji, media));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IElement CreateInlineFormattingElement(IDocument document, string name)
|
private IElement CreateInlineFormattingElement(IDocument document, string name)
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using AngleSharp.Html.Dom;
|
using AngleSharp.Html.Dom;
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
|
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
|
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
|
||||||
|
|
||||||
internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions)
|
internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions, ICollection<MfmInlineMedia> media)
|
||||||
{
|
{
|
||||||
internal string? ParseNode(INode node)
|
internal string? ParseNode(INode node)
|
||||||
{
|
{
|
||||||
|
@ -21,8 +22,8 @@ internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions)
|
||||||
}
|
}
|
||||||
case "A":
|
case "A":
|
||||||
{
|
{
|
||||||
if (node is HtmlElement el)
|
if (node is not HtmlElement el) return node.TextContent;
|
||||||
{
|
|
||||||
var href = el.GetAttribute("href");
|
var href = el.GetAttribute("href");
|
||||||
if (href == null) return $"<plain>{el.TextContent}</plain>";
|
if (href == null) return $"<plain>{el.TextContent}</plain>";
|
||||||
|
|
||||||
|
@ -39,9 +40,6 @@ internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions)
|
||||||
|
|
||||||
return $"[{el.TextContent}]({href})";
|
return $"[{el.TextContent}]({href})";
|
||||||
}
|
}
|
||||||
|
|
||||||
return node.TextContent;
|
|
||||||
}
|
|
||||||
case "H1":
|
case "H1":
|
||||||
{
|
{
|
||||||
return $"【{ParseChildren(node)}】\n";
|
return $"【{ParseChildren(node)}】\n";
|
||||||
|
@ -82,6 +80,31 @@ internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "VIDEO":
|
||||||
|
case "AUDIO":
|
||||||
|
case "IMG":
|
||||||
|
{
|
||||||
|
if (node is not HtmlElement el) return node.TextContent;
|
||||||
|
|
||||||
|
var src = el.GetAttribute("src");
|
||||||
|
if (!Uri.IsWellFormedUriString(src, UriKind.Absolute))
|
||||||
|
return node.TextContent;
|
||||||
|
|
||||||
|
var alt = el.GetAttribute("alt") ?? el.GetAttribute("title");
|
||||||
|
|
||||||
|
var type = node.NodeName switch
|
||||||
|
{
|
||||||
|
"VIDEO" => MfmInlineMedia.MediaType.Video,
|
||||||
|
"AUDIO" => MfmInlineMedia.MediaType.Audio,
|
||||||
|
"IMG" => MfmInlineMedia.MediaType.Image,
|
||||||
|
_ => MfmInlineMedia.MediaType.Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
media.Add(new MfmInlineMedia(type, src, alt));
|
||||||
|
|
||||||
|
return $"$[media {src} ]";
|
||||||
|
}
|
||||||
|
|
||||||
case "P":
|
case "P":
|
||||||
case "H2":
|
case "H2":
|
||||||
case "H3":
|
case "H3":
|
||||||
|
|
|
@ -532,6 +532,7 @@ public class NoteService(
|
||||||
{
|
{
|
||||||
nodes = MfmParser.Parse(data.Text);
|
nodes = MfmParser.Parse(data.Text);
|
||||||
mentionsResolver.ResolveMentions(nodes.AsSpan(), note.User.Host, mentions, splitDomainMapping);
|
mentionsResolver.ResolveMentions(nodes.AsSpan(), note.User.Host, mentions, splitDomainMapping);
|
||||||
|
// todo scan nodes recursively to find inline media and make sure note.fileids has them
|
||||||
data.Text = nodes.Serialize();
|
data.Text = nodes.Serialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -942,7 +943,15 @@ public class NoteService(
|
||||||
var renote = quoteUrl != null ? await ResolveNoteAsync(quoteUrl, user: user) : null;
|
var renote = quoteUrl != null ? await ResolveNoteAsync(quoteUrl, user: user) : null;
|
||||||
var renoteUri = renote == null ? quoteUrl : null;
|
var renoteUri = renote == null ? quoteUrl : null;
|
||||||
var visibility = note.GetVisibility(actor);
|
var visibility = note.GetVisibility(actor);
|
||||||
var text = note.MkContent ?? await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
|
||||||
|
var text = note.MkContent;
|
||||||
|
List<MfmInlineMedia>? htmlInlineMedia = null;
|
||||||
|
|
||||||
|
if (text == null)
|
||||||
|
{
|
||||||
|
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
||||||
|
}
|
||||||
|
|
||||||
var cw = note.Summary;
|
var cw = note.Summary;
|
||||||
var url = note.Url?.Link;
|
var url = note.Url?.Link;
|
||||||
var uri = note.Id;
|
var uri = note.Id;
|
||||||
|
@ -969,7 +978,7 @@ public class NoteService(
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensitive = (note.Sensitive ?? false) || !string.IsNullOrWhiteSpace(cw);
|
var sensitive = (note.Sensitive ?? false) || !string.IsNullOrWhiteSpace(cw);
|
||||||
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
|
var files = await ProcessAttachmentsAsync(note.Attachments, htmlInlineMedia, actor, sensitive);
|
||||||
var emoji = (await emojiSvc.ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host))
|
var emoji = (await emojiSvc.ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host))
|
||||||
.Select(p => p.Id)
|
.Select(p => p.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
@ -1026,7 +1035,14 @@ public class NoteService(
|
||||||
|
|
||||||
var mentionData = await ResolveNoteMentionsAsync(note);
|
var mentionData = await ResolveNoteMentionsAsync(note);
|
||||||
|
|
||||||
var text = note.MkContent ?? await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
var text = note.MkContent;
|
||||||
|
List<MfmInlineMedia>? htmlInlineMedia = null;
|
||||||
|
|
||||||
|
if (text == null)
|
||||||
|
{
|
||||||
|
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions);
|
||||||
|
}
|
||||||
|
|
||||||
var cw = note.Summary;
|
var cw = note.Summary;
|
||||||
|
|
||||||
Poll? poll = null;
|
Poll? poll = null;
|
||||||
|
@ -1055,7 +1071,7 @@ public class NoteService(
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensitive = (note.Sensitive ?? false) || !string.IsNullOrWhiteSpace(cw);
|
var sensitive = (note.Sensitive ?? false) || !string.IsNullOrWhiteSpace(cw);
|
||||||
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive, false);
|
var files = await ProcessAttachmentsAsync(note.Attachments, htmlInlineMedia, actor, sensitive, false);
|
||||||
var emoji = (await emojiSvc.ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host))
|
var emoji = (await emojiSvc.ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host))
|
||||||
.Select(p => p.Id)
|
.Select(p => p.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
@ -1186,13 +1202,28 @@ public class NoteService(
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<DriveFile>> ProcessAttachmentsAsync(
|
private async Task<List<DriveFile>> ProcessAttachmentsAsync(
|
||||||
List<ASAttachment>? attachments, User user, bool sensitive, bool logExisting = true
|
List<ASAttachment>? attachments, List<MfmInlineMedia>? htmlInlineMedia, User user, bool sensitive,
|
||||||
|
bool logExisting = true
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (attachments is not { Count: > 0 }) return [];
|
var allAttachments = attachments?.OfType<ASDocument>().Take(10).ToList() ?? [];
|
||||||
var result = await attachments
|
|
||||||
.OfType<ASDocument>()
|
if (htmlInlineMedia != null)
|
||||||
.Take(10)
|
{
|
||||||
|
var inlineUrls = htmlInlineMedia.Select(p => p.Src);
|
||||||
|
var unattachedUrls = inlineUrls.Except(allAttachments.Select(a => a.Url?.Id)).ToArray();
|
||||||
|
|
||||||
|
allAttachments.AddRange(htmlInlineMedia.Where(p => unattachedUrls.Contains(p.Src))
|
||||||
|
.DistinctBy(p => p.Src)
|
||||||
|
.Select(p => new ASDocument
|
||||||
|
{
|
||||||
|
Url = new ASLink(p.Src),
|
||||||
|
Description = p.Alt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allAttachments is not { Count: > 0 }) return [];
|
||||||
|
var result = await allAttachments
|
||||||
.Select(p => driveSvc.StoreFileAsync(p.Url?.Id, user, p.Sensitive ?? sensitive,
|
.Select(p => driveSvc.StoreFileAsync(p.Url?.Id, user, p.Sensitive ?? sensitive,
|
||||||
p.Description, p.MediaType, logExisting))
|
p.Description, p.MediaType, logExisting))
|
||||||
.AwaitAllNoConcurrencyAsync();
|
.AwaitAllNoConcurrencyAsync();
|
||||||
|
|
|
@ -150,12 +150,12 @@ public class UserService(
|
||||||
.Where(p => p is { Name: not null, Value: not null })
|
.Where(p => p is { Name: not null, Value: not null })
|
||||||
.Select(async p => new UserProfile.Field
|
.Select(async p => new UserProfile.Field
|
||||||
{
|
{
|
||||||
Name = p.Name!, Value = await MfmConverter.FromHtmlAsync(p.Value) ?? ""
|
Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
|
||||||
})
|
})
|
||||||
.AwaitAllAsync()
|
.AwaitAllAsync()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var bio = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary);
|
var bio = actor.MkSummary ?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||||
|
|
||||||
var tags = ResolveHashtags(bio, actor);
|
var tags = ResolveHashtags(bio, actor);
|
||||||
|
|
||||||
|
@ -303,7 +303,7 @@ public class UserService(
|
||||||
.Where(p => p is { Name: not null, Value: not null })
|
.Where(p => p is { Name: not null, Value: not null })
|
||||||
.Select(async p => new UserProfile.Field
|
.Select(async p => new UserProfile.Field
|
||||||
{
|
{
|
||||||
Name = p.Name!, Value = await MfmConverter.FromHtmlAsync(p.Value) ?? ""
|
Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
|
||||||
})
|
})
|
||||||
.AwaitAllAsync()
|
.AwaitAllAsync()
|
||||||
: null;
|
: null;
|
||||||
|
@ -316,7 +316,7 @@ public class UserService(
|
||||||
|
|
||||||
var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
|
var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
|
||||||
|
|
||||||
user.UserProfile.Description = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary);
|
user.UserProfile.Description = actor.MkSummary ?? (await MfmConverter.FromHtmlAsync(actor.Summary)).Mfm;
|
||||||
//user.UserProfile.Birthday = TODO;
|
//user.UserProfile.Birthday = TODO;
|
||||||
//user.UserProfile.Location = TODO;
|
//user.UserProfile.Location = TODO;
|
||||||
user.UserProfile.Fields = fields?.ToArray() ?? [];
|
user.UserProfile.Fields = fields?.ToArray() ?? [];
|
||||||
|
@ -1065,14 +1065,14 @@ public class UserService(
|
||||||
.Select(async p => new UserProfile.Field
|
.Select(async p => new UserProfile.Field
|
||||||
{
|
{
|
||||||
Name = p.Name!,
|
Name = p.Name!,
|
||||||
Value = await MfmConverter.FromHtmlAsync(p.Value, mentions) ?? ""
|
Value = (await MfmConverter.FromHtmlAsync(p.Value, mentions)).Mfm
|
||||||
})
|
})
|
||||||
.AwaitAllAsync()
|
.AwaitAllAsync()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var description = actor.MkSummary != null
|
var description = actor.MkSummary != null
|
||||||
? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping)
|
? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping)
|
||||||
: await MfmConverter.FromHtmlAsync(actor.Summary, mentions);
|
: (await MfmConverter.FromHtmlAsync(actor.Summary, mentions)).Mfm;
|
||||||
|
|
||||||
bgUser.UserProfile.Mentions = mentions;
|
bgUser.UserProfile.Mentions = mentions;
|
||||||
bgUser.UserProfile.Fields = fields?.ToArray() ?? [];
|
bgUser.UserProfile.Fields = fields?.ToArray() ?? [];
|
||||||
|
|
Loading…
Add table
Reference in a new issue