PlainText(string text, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(text))
return Decorate(string.Empty, token);
var paragraphs = Regex.Split(text.Replace("\r\n", "\n").Trim(), "\n{2,}")
.Select(p => "" + Linkify(WebUtility.HtmlEncode(p)).Replace("\n", "
") + "
");
return Decorate(string.Concat(paragraphs), token);
}
async Task Decorate(string html, CancellationToken token)
{
var document = Parser.ParseDocument($"{html}");
var textNodes = document.Body.Descendants()
.Where(node => !node.Ancestors().Any(e => e.LocalName is "a" or "code" or "pre"))
.ToList();
var handles = textNodes.SelectMany(node => MentionPattern().Matches(node.Data).Select(m => m.Value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxMentions)
.ToList();
var mentions = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var handle in handles)
{
var resolved = await Resolve(handle, token);
if (resolved != default)
mentions[handle] = resolved;
}
var tags = new List();
foreach (var node in textNodes)
{
var replaced = Replace(node.Data, mentions, tags);
if (replaced == default)
continue;
var fragment = Parser.ParseFragment(replaced, node.ParentElement);
foreach (var child in fragment.ToList())
node.Parent.InsertBefore(child, node);
node.Parent.RemoveChild(node);
}
return new RenderedContent(
ContentSanitizer.Html(document.Body.InnerHtml),
mentions.Values.DistinctBy(m => m.ActorUri).ToList(),
tags.Distinct().ToList());
}
string Replace(string text, IReadOnlyDictionary mentions, List tags)
{
var changed = false;
var encoded = WebUtility.HtmlEncode(text);
encoded = MentionPattern().Replace(encoded, match =>
{
if (!mentions.TryGetValue(WebUtility.HtmlDecode(match.Value), out var mention))
return match.Value;
changed = true;
return $"@{WebUtility.HtmlEncode(mention.UserName)}";
});
encoded = HashtagPattern().Replace(encoded, match =>
{
var tag = match.Groups["tag"].Value;
if (tag.All(char.IsDigit))
return match.Value;
changed = true;
tags.Add(tag.ToLowerInvariant());
return $"{match.Groups["lead"].Value}#{tag}";
});
return changed ? encoded : default;
}
async Task Resolve(string handle, CancellationToken token)
{
var parts = handle.TrimStart('@').Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
var actor = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
return actor == default
? default
: new ResolvedMention(actor.ActorURI, actor.UserName ?? parts[0], parts[1].ToLowerInvariant(), actor.Url ?? actor.ActorURI, false, actor.ID,
actor.InboxURL);
}
static string Linkify(string encoded) =>
UrlPattern().Replace(encoded, match => $"{match.Value}");
[GeneratedRegex(@"(?^|[^\w/])#(?[\p{L}\p{N}_]{1,100})(?![\p{L}\p{N}_])")]
private static partial Regex HashtagPattern();
[GeneratedRegex(@"https?://[^\s<]+[^\s<.,;:!?)\]'""]")]
private static partial Regex UrlPattern();
}
}