Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.
Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
bold first line, a content warning without its own text is summarised
by the title or "Content warning"; Mention and Hashtag tags, contentMap,
updated, and to/cc by visibility.
Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
92 lines
4.5 KiB
C#
92 lines
4.5 KiB
C#
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.User;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
public class ContentRendererTests
|
|
{
|
|
const string Base = "https://privapub.test";
|
|
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
|
|
|
|
[Fact]
|
|
public async Task Decorates_local_and_remote_mentions_and_hashtags()
|
|
{
|
|
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
|
|
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
|
|
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
|
|
Assert.Contains("<strong>now</strong>", rendered.Html);
|
|
Assert.DoesNotContain("<script>", rendered.Html);
|
|
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
|
|
Assert.Equal("fedi", Assert.Single(rendered.Tags));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("`@alice` in code")]
|
|
[InlineData("mail me@alice.example")]
|
|
[InlineData("@nobody is unknown")]
|
|
[InlineData("see https://x.example/page#alice")]
|
|
public async Task Leaves_what_is_not_a_mention_alone(string text)
|
|
{
|
|
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Empty(rendered.Mentions);
|
|
Assert.DoesNotContain("h-card", rendered.Html);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("issue #123")]
|
|
[InlineData("it's fine")]
|
|
[InlineData("[link](https://x.example/#tag)")]
|
|
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
|
|
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
|
|
|
|
[Fact]
|
|
public async Task Renders_plain_text_like_mastodon()
|
|
{
|
|
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal("<p>line one<br>line <two></p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
|
|
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
|
|
}
|
|
|
|
sealed class StubLocalActors : ILocalActorService
|
|
{
|
|
public string BaseAddress => Base;
|
|
|
|
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
|
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
|
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
|
: default);
|
|
|
|
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
|
|
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
|
|
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
|
|
}
|
|
|
|
sealed class StubRemoteActors : IRemoteActorService
|
|
{
|
|
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
|
|
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
|
|
|
|
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
|
|
Task.FromResult(actorUri == "https://m.example/users/bob"
|
|
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
|
|
: default);
|
|
|
|
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
|
|
}
|
|
}
|
|
}
|