Outgoing mentions mostly work (exc. cc followers)
This commit is contained in:
parent
a80e0f117a
commit
53d9452917
|
@ -37,7 +37,7 @@ the less sure I am about it.
|
||||||
- [x] Undo follows
|
- [x] Undo follows
|
||||||
- [x] Receive and accept follows
|
- [x] Receive and accept follows
|
||||||
- [x] Receive follow undos
|
- [x] Receive follow undos
|
||||||
- [ ] Do outgoing mentions properly
|
- [x] Do outgoing mentions properly
|
||||||
- [x] Home timeline (posts and boosts from follows)
|
- [x] Home timeline (posts and boosts from follows)
|
||||||
- [x] Notifications page (followed, boosted, liked)
|
- [x] Notifications page (followed, boosted, liked)
|
||||||
- [x] Local timeline
|
- [x] Local timeline
|
||||||
|
|
|
@ -4,12 +4,13 @@ from typing import Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
|
from django.template.defaultfilters import linebreaks_filter
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from activities.models.fan_out import FanOut
|
from activities.models.fan_out import FanOut
|
||||||
from activities.models.timeline_event import TimelineEvent
|
from activities.models.timeline_event import TimelineEvent
|
||||||
from core.html import sanitize_post
|
from core.html import sanitize_post, strip_html
|
||||||
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
from users.models.follow import Follow
|
from users.models.follow import Follow
|
||||||
|
@ -134,7 +135,6 @@ class Post(StatorModel):
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view = "{self.author.urls.view}posts/{self.id}/"
|
view = "{self.author.urls.view}posts/{self.id}/"
|
||||||
view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
|
|
||||||
object_uri = "{self.author.actor_uri}posts/{self.id}/"
|
object_uri = "{self.author.actor_uri}posts/{self.id}/"
|
||||||
action_like = "{view}like/"
|
action_like = "{view}like/"
|
||||||
action_unlike = "{view}unlike/"
|
action_unlike = "{view}unlike/"
|
||||||
|
@ -153,42 +153,58 @@ class Post(StatorModel):
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.urls.view
|
return self.urls.view
|
||||||
|
|
||||||
|
def absolute_object_uri(self):
|
||||||
|
"""
|
||||||
|
Returns an object URI that is always absolute, for sending out to
|
||||||
|
other servers.
|
||||||
|
"""
|
||||||
|
if self.local:
|
||||||
|
return self.author.absolute_profile_uri() + f"posts/{self.id}/"
|
||||||
|
else:
|
||||||
|
return self.object_uri
|
||||||
|
|
||||||
### Content cleanup and extraction ###
|
### Content cleanup and extraction ###
|
||||||
|
|
||||||
mention_regex = re.compile(
|
mention_regex = re.compile(
|
||||||
r"([^\w\d\-_])(@[\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)"
|
r"([^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def linkify_mentions(self, content):
|
def linkify_mentions(self, content, local=False):
|
||||||
"""
|
"""
|
||||||
Links mentions _in the context of the post_ - meaning that if there's
|
Links mentions _in the context of the post_ - as in, using the mentions
|
||||||
a short @andrew mention, it will look at the mentions link to resolve
|
property as the only source (as we might be doing this without other
|
||||||
it rather than presuming it's local.
|
DB access allowed)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
possible_matches = {}
|
||||||
|
for mention in self.mentions.all():
|
||||||
|
if local:
|
||||||
|
url = str(mention.urls.view)
|
||||||
|
else:
|
||||||
|
url = mention.absolute_profile_uri()
|
||||||
|
possible_matches[mention.username] = url
|
||||||
|
possible_matches[f"{mention.username}@{mention.domain_id}"] = url
|
||||||
|
|
||||||
def replacer(match):
|
def replacer(match):
|
||||||
precursor = match.group(1)
|
precursor = match.group(1)
|
||||||
handle = match.group(2)
|
handle = match.group(2)
|
||||||
# If the handle has no domain, try to match it with a mention
|
if handle in possible_matches:
|
||||||
if "@" not in handle.lstrip("@"):
|
return f'{precursor}<a href="{possible_matches[handle]}">@{handle}</a>'
|
||||||
username = handle.lstrip("@")
|
|
||||||
identity = self.mentions.filter(username=username).first()
|
|
||||||
if identity:
|
|
||||||
url = identity.urls.view
|
|
||||||
else:
|
|
||||||
url = f"/@{username}/"
|
|
||||||
else:
|
|
||||||
url = f"/{handle}/"
|
|
||||||
# If we have a URL, link to it, otherwise don't link
|
|
||||||
if url:
|
|
||||||
return f'{precursor}<a href="{url}">{handle}</a>'
|
|
||||||
else:
|
else:
|
||||||
return match.group()
|
return match.group()
|
||||||
|
|
||||||
return mark_safe(self.mention_regex.sub(replacer, content))
|
return mark_safe(self.mention_regex.sub(replacer, content))
|
||||||
|
|
||||||
@property
|
def safe_content_local(self):
|
||||||
def safe_content(self):
|
"""
|
||||||
|
Returns the content formatted for local display
|
||||||
|
"""
|
||||||
|
return self.linkify_mentions(sanitize_post(self.content), local=True)
|
||||||
|
|
||||||
|
def safe_content_remote(self):
|
||||||
|
"""
|
||||||
|
Returns the content formatted for remote consumption
|
||||||
|
"""
|
||||||
return self.linkify_mentions(sanitize_post(self.content))
|
return self.linkify_mentions(sanitize_post(self.content))
|
||||||
|
|
||||||
### Async helpers ###
|
### Async helpers ###
|
||||||
|
@ -197,8 +213,10 @@ class Post(StatorModel):
|
||||||
"""
|
"""
|
||||||
Returns a version of the object with all relations pre-loaded
|
Returns a version of the object with all relations pre-loaded
|
||||||
"""
|
"""
|
||||||
return await Post.objects.select_related("author", "author__domain").aget(
|
return (
|
||||||
pk=self.pk
|
await Post.objects.select_related("author", "author__domain")
|
||||||
|
.prefetch_related("mentions", "mentions__domain")
|
||||||
|
.aget(pk=self.pk)
|
||||||
)
|
)
|
||||||
|
|
||||||
### Local creation ###
|
### Local creation ###
|
||||||
|
@ -212,6 +230,25 @@ class Post(StatorModel):
|
||||||
visibility: int = Visibilities.public,
|
visibility: int = Visibilities.public,
|
||||||
) -> "Post":
|
) -> "Post":
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
# Find mentions in this post
|
||||||
|
mention_hits = cls.mention_regex.findall(content)
|
||||||
|
mentions = set()
|
||||||
|
for precursor, handle in mention_hits:
|
||||||
|
if "@" in handle:
|
||||||
|
username, domain = handle.split("@", 1)
|
||||||
|
else:
|
||||||
|
username = handle
|
||||||
|
domain = author.domain_id
|
||||||
|
identity = Identity.by_username_and_domain(
|
||||||
|
username=username,
|
||||||
|
domain=domain,
|
||||||
|
fetch=True,
|
||||||
|
)
|
||||||
|
if identity is not None:
|
||||||
|
mentions.add(identity)
|
||||||
|
# Strip all HTML and apply linebreaks filter
|
||||||
|
content = linebreaks_filter(strip_html(content))
|
||||||
|
# Make the Post object
|
||||||
post = cls.objects.create(
|
post = cls.objects.create(
|
||||||
author=author,
|
author=author,
|
||||||
content=content,
|
content=content,
|
||||||
|
@ -221,7 +258,8 @@ class Post(StatorModel):
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
)
|
)
|
||||||
post.object_uri = post.urls.object_uri
|
post.object_uri = post.urls.object_uri
|
||||||
post.url = post.urls.view_nice
|
post.url = post.absolute_object_uri()
|
||||||
|
post.mentions.set(mentions)
|
||||||
post.save()
|
post.save()
|
||||||
return post
|
return post
|
||||||
|
|
||||||
|
@ -232,28 +270,48 @@ class Post(StatorModel):
|
||||||
Returns the AP JSON for this object
|
Returns the AP JSON for this object
|
||||||
"""
|
"""
|
||||||
value = {
|
value = {
|
||||||
|
"to": "as:Public",
|
||||||
|
"cc": [],
|
||||||
"type": "Note",
|
"type": "Note",
|
||||||
"id": self.object_uri,
|
"id": self.object_uri,
|
||||||
"published": format_ld_date(self.published),
|
"published": format_ld_date(self.published),
|
||||||
"attributedTo": self.author.actor_uri,
|
"attributedTo": self.author.actor_uri,
|
||||||
"content": self.safe_content,
|
"content": self.safe_content_remote(),
|
||||||
"to": "as:Public",
|
|
||||||
"as:sensitive": self.sensitive,
|
"as:sensitive": self.sensitive,
|
||||||
"url": str(self.urls.view_nice if self.local else self.url),
|
"url": self.absolute_object_uri(),
|
||||||
|
"tag": [],
|
||||||
}
|
}
|
||||||
if self.summary:
|
if self.summary:
|
||||||
value["summary"] = self.summary
|
value["summary"] = self.summary
|
||||||
|
# Mentions
|
||||||
|
for mention in self.mentions.all():
|
||||||
|
value["tag"].append(
|
||||||
|
{
|
||||||
|
"href": mention.actor_uri,
|
||||||
|
"name": "@" + mention.handle,
|
||||||
|
"type": "Mention",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
value["cc"].append(mention.actor_uri)
|
||||||
|
# Remove tag and cc if they're empty
|
||||||
|
if not value["cc"]:
|
||||||
|
del value["cc"]
|
||||||
|
if not value["tag"]:
|
||||||
|
del value["tag"]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def to_create_ap(self):
|
def to_create_ap(self):
|
||||||
"""
|
"""
|
||||||
Returns the AP JSON to create this object
|
Returns the AP JSON to create this object
|
||||||
"""
|
"""
|
||||||
|
object = self.to_ap()
|
||||||
return {
|
return {
|
||||||
|
"to": object["to"],
|
||||||
|
"cc": object.get("cc", []),
|
||||||
"type": "Create",
|
"type": "Create",
|
||||||
"id": self.object_uri + "#create",
|
"id": self.object_uri + "#create",
|
||||||
"actor": self.author.actor_uri,
|
"actor": self.author.actor_uri,
|
||||||
"object": self.to_ap(),
|
"object": object,
|
||||||
}
|
}
|
||||||
|
|
||||||
### ActivityPub (inbound) ###
|
### ActivityPub (inbound) ###
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template.defaultfilters import linebreaks_filter
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.generic import FormView, TemplateView, View
|
from django.views.generic import FormView, TemplateView, View
|
||||||
|
|
||||||
|
@ -158,7 +157,7 @@ class Compose(FormView):
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
Post.create_local(
|
Post.create_local(
|
||||||
author=self.request.identity,
|
author=self.request.identity,
|
||||||
content=linebreaks_filter(form.cleaned_data["text"]),
|
content=form.cleaned_data["text"],
|
||||||
summary=form.cleaned_data.get("content_warning"),
|
summary=form.cleaned_data.get("content_warning"),
|
||||||
visibility=form.cleaned_data["visibility"],
|
visibility=form.cleaned_data["visibility"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -30,3 +30,11 @@ def sanitize_post(post_html: str) -> str:
|
||||||
strip=True,
|
strip=True,
|
||||||
)
|
)
|
||||||
return mark_safe(cleaner.clean(post_html))
|
return mark_safe(cleaner.clean(post_html))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html(post_html: str) -> str:
|
||||||
|
"""
|
||||||
|
Strips all tags from the text, then linkifies it.
|
||||||
|
"""
|
||||||
|
cleaner = bleach.Cleaner(tags=[], strip=True, filters=[LinkifyFilter])
|
||||||
|
return mark_safe(cleaner.clean(post_html))
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="content {% if post.summary %}hidden{% endif %}">
|
<div class="content {% if post.summary %}hidden{% endif %}">
|
||||||
{{ post.safe_content }}
|
{{ post.safe_content_local }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if post.attachments.exists %}
|
{% if post.attachments.exists %}
|
||||||
|
|
|
@ -32,32 +32,72 @@ def test_fetch_post(httpx_mock: HTTPXMock):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_linkify_mentions(identity, remote_identity):
|
def test_linkify_mentions_remote(identity, remote_identity):
|
||||||
"""
|
"""
|
||||||
Tests that we can linkify post mentions properly
|
Tests that we can linkify post mentions properly for remote use
|
||||||
"""
|
"""
|
||||||
# Test a short username without a mention (presumed local)
|
# Test a short username (remote)
|
||||||
post = Post.objects.create(
|
|
||||||
content="<p>Hello @test</p>",
|
|
||||||
author=identity,
|
|
||||||
local=True,
|
|
||||||
)
|
|
||||||
assert post.safe_content == '<p>Hello <a href="/@test/">@test</a></p>'
|
|
||||||
# Test a full username
|
|
||||||
post = Post.objects.create(
|
|
||||||
content="<p>@test@example.com, welcome!</p>",
|
|
||||||
author=identity,
|
|
||||||
local=True,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
post.safe_content
|
|
||||||
== '<p><a href="/@test@example.com/">@test@example.com</a>, welcome!</p>'
|
|
||||||
)
|
|
||||||
# Test a short username with a mention resolving to remote
|
|
||||||
post = Post.objects.create(
|
post = Post.objects.create(
|
||||||
content="<p>Hello @test</p>",
|
content="<p>Hello @test</p>",
|
||||||
author=identity,
|
author=identity,
|
||||||
local=True,
|
local=True,
|
||||||
)
|
)
|
||||||
post.mentions.add(remote_identity)
|
post.mentions.add(remote_identity)
|
||||||
assert post.safe_content == '<p>Hello <a href="/@test@remote.test/">@test</a></p>'
|
assert (
|
||||||
|
post.safe_content_remote()
|
||||||
|
== '<p>Hello <a href="https://remote.test/@test/">@test</a></p>'
|
||||||
|
)
|
||||||
|
# Test a full username (local)
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>@test@example.com, welcome!</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
post.mentions.add(identity)
|
||||||
|
assert (
|
||||||
|
post.safe_content_remote()
|
||||||
|
== '<p><a href="https://example.com/@test/">@test@example.com</a>, welcome!</p>'
|
||||||
|
)
|
||||||
|
# Test that they don't get touched without a mention
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>@test@example.com, welcome!</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
assert post.safe_content_remote() == "<p>@test@example.com, welcome!</p>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_linkify_mentions_local(identity, remote_identity):
|
||||||
|
"""
|
||||||
|
Tests that we can linkify post mentions properly for local use
|
||||||
|
"""
|
||||||
|
# Test a short username (remote)
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>Hello @test</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
post.mentions.add(remote_identity)
|
||||||
|
assert (
|
||||||
|
post.safe_content_local()
|
||||||
|
== '<p>Hello <a href="/@test@remote.test/">@test</a></p>'
|
||||||
|
)
|
||||||
|
# Test a full username (local)
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>@test@example.com, welcome!</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
post.mentions.add(identity)
|
||||||
|
assert (
|
||||||
|
post.safe_content_local()
|
||||||
|
== '<p><a href="/@test@example.com/">@test@example.com</a>, welcome!</p>'
|
||||||
|
)
|
||||||
|
# Test that they don't get touched without a mention
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>@test@example.com, welcome!</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>"
|
||||||
|
|
|
@ -68,14 +68,15 @@ def identity():
|
||||||
"""
|
"""
|
||||||
user = User.objects.create(email="test@example.com")
|
user = User.objects.create(email="test@example.com")
|
||||||
domain = Domain.objects.create(domain="example.com", local=True, public=True)
|
domain = Domain.objects.create(domain="example.com", local=True, public=True)
|
||||||
return Identity.objects.create(
|
identity = Identity.objects.create(
|
||||||
actor_uri="https://example.com/test-actor/",
|
actor_uri="https://example.com/test-actor/",
|
||||||
username="test",
|
username="test",
|
||||||
domain=domain,
|
domain=domain,
|
||||||
user=user,
|
|
||||||
name="Test User",
|
name="Test User",
|
||||||
local=True,
|
local=True,
|
||||||
)
|
)
|
||||||
|
identity.users.set([user])
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -87,6 +88,7 @@ def remote_identity():
|
||||||
domain = Domain.objects.create(domain="remote.test", local=False)
|
domain = Domain.objects.create(domain="remote.test", local=False)
|
||||||
return Identity.objects.create(
|
return Identity.objects.create(
|
||||||
actor_uri="https://remote.test/test-actor/",
|
actor_uri="https://remote.test/test-actor/",
|
||||||
|
profile_uri="https://remote.test/@test/",
|
||||||
username="test",
|
username="test",
|
||||||
domain=domain,
|
domain=domain,
|
||||||
name="Test Remote User",
|
name="Test Remote User",
|
||||||
|
|
|
@ -97,7 +97,6 @@ class Identity(StatorModel):
|
||||||
unique_together = [("username", "domain")]
|
unique_together = [("username", "domain")]
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view_nice = "{self._nice_view_url}"
|
|
||||||
view = "/@{self.username}@{self.domain_id}/"
|
view = "/@{self.username}@{self.domain_id}/"
|
||||||
action = "{view}action/"
|
action = "{view}action/"
|
||||||
activate = "{view}activate/"
|
activate = "{view}activate/"
|
||||||
|
@ -113,14 +112,15 @@ class Identity(StatorModel):
|
||||||
return self.handle
|
return self.handle
|
||||||
return self.actor_uri
|
return self.actor_uri
|
||||||
|
|
||||||
def _nice_view_url(self):
|
def absolute_profile_uri(self):
|
||||||
"""
|
"""
|
||||||
Returns the "nice" user URL if they're local, otherwise our general one
|
Returns a profile URI that is always absolute, for sending out to
|
||||||
|
other servers.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
return f"https://{self.domain.uri_domain}/@{self.username}/"
|
return f"https://{self.domain.uri_domain}/@{self.username}/"
|
||||||
else:
|
else:
|
||||||
return f"/@{self.username}@{self.domain_id}/"
|
return self.profile_uri
|
||||||
|
|
||||||
def local_icon_url(self):
|
def local_icon_url(self):
|
||||||
"""
|
"""
|
||||||
|
@ -206,7 +206,7 @@ class Identity(StatorModel):
|
||||||
def handle(self):
|
def handle(self):
|
||||||
if self.domain_id:
|
if self.domain_id:
|
||||||
return f"{self.username}@{self.domain_id}"
|
return f"{self.username}@{self.domain_id}"
|
||||||
return f"{self.username}@UNKNOWN-DOMAIN"
|
return f"{self.username}@unknown.invalid"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data_age(self) -> float:
|
def data_age(self) -> float:
|
||||||
|
@ -238,7 +238,7 @@ class Identity(StatorModel):
|
||||||
"publicKeyPem": self.public_key,
|
"publicKeyPem": self.public_key,
|
||||||
},
|
},
|
||||||
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"url": str(self.urls.view_nice),
|
"url": self.absolute_profile_uri(),
|
||||||
}
|
}
|
||||||
if self.name:
|
if self.name:
|
||||||
response["name"] = self.name
|
response["name"] = self.name
|
||||||
|
|
|
@ -128,13 +128,13 @@ class Webfinger(View):
|
||||||
{
|
{
|
||||||
"subject": f"acct:{identity.handle}",
|
"subject": f"acct:{identity.handle}",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
str(identity.urls.view_nice),
|
identity.absolute_profile_uri(),
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
"type": "text/html",
|
"type": "text/html",
|
||||||
"href": str(identity.urls.view_nice),
|
"href": identity.absolute_profile_uri(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "self",
|
"rel": "self",
|
||||||
|
|
Loading…
Reference in New Issue