Display Webmention as replies when applicable

This commit is contained in:
Thomas Sileo 2022-11-18 20:20:58 +01:00
parent ae8029cd22
commit 120f92a9ed
5 changed files with 124 additions and 6 deletions

View file

@ -1,4 +1,5 @@
"""Actions related to the AP inbox/outbox.""" """Actions related to the AP inbox/outbox."""
import datetime
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
@ -42,6 +43,7 @@ from app.utils.datetime import as_utc
from app.utils.datetime import now from app.utils.datetime import now
from app.utils.datetime import parse_isoformat from app.utils.datetime import parse_isoformat
from app.utils.text import slugify from app.utils.text import slugify
from app.utils.facepile import WebmentionReply
AnyboxObject = models.InboxObject | models.OutboxObject AnyboxObject = models.InboxObject | models.OutboxObject
@ -2581,11 +2583,21 @@ async def fetch_actor_collection(db_session: AsyncSession, url: str) -> list[Act
@dataclass @dataclass
class ReplyTreeNode: class ReplyTreeNode:
ap_object: AnyboxObject ap_object: AnyboxObject | None
wm_reply: WebmentionReply | None
children: list["ReplyTreeNode"] children: list["ReplyTreeNode"]
is_requested: bool = False is_requested: bool = False
is_root: bool = False is_root: bool = False
@property
def published_at(self) -> datetime.datetime:
if self.ap_object:
return self.ap_object.ap_published_at
elif self.wm_reply:
return self.wm_reply.published_at
else:
raise ValueError(f"Should never happen: {self}")
async def get_replies_tree( async def get_replies_tree(
db_session: AsyncSession, db_session: AsyncSession,
@ -2659,6 +2671,7 @@ async def get_replies_tree(
for child in index.get(node.ap_object.ap_id, []): # type: ignore for child in index.get(node.ap_object.ap_id, []): # type: ignore
child_node = ReplyTreeNode( child_node = ReplyTreeNode(
ap_object=child, ap_object=child,
wm_reply=None,
is_requested=child.ap_id == requested_object.ap_id, # type: ignore is_requested=child.ap_id == requested_object.ap_id, # type: ignore
children=[], children=[],
) )
@ -2667,7 +2680,7 @@ async def get_replies_tree(
return sorted( return sorted(
children, children,
key=lambda node: node.ap_object.ap_published_at, # type: ignore key=lambda node: node.published_at,
) )
if None in nodes_by_in_reply_to: if None in nodes_by_in_reply_to:
@ -2680,6 +2693,7 @@ async def get_replies_tree(
root_node = ReplyTreeNode( root_node = ReplyTreeNode(
ap_object=root_ap_object, ap_object=root_ap_object,
wm_reply=None,
is_root=True, is_root=True,
is_requested=root_ap_object.ap_id == requested_object.ap_id, is_requested=root_ap_object.ap_id == requested_object.ap_id,
children=[], children=[],

View file

@ -75,6 +75,7 @@ from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME from app.utils.emoji import EMOJIS_BY_NAME
from app.utils.facepile import Face from app.utils.facepile import Face
from app.utils.facepile import merge_faces from app.utils.facepile import merge_faces
from app.utils.facepile import WebmentionReply
from app.utils.highlight import HIGHLIGHT_CSS_HASH from app.utils.highlight import HIGHLIGHT_CSS_HASH
from app.utils.url import check_url from app.utils.url import check_url
from app.webfinger import get_remote_follow_template from app.webfinger import get_remote_follow_template
@ -784,7 +785,7 @@ async def outbox_by_public_id(
request, request,
"object.html", "object.html",
{ {
"replies_tree": replies_tree, "replies_tree": _merge_replies(replies_tree, webmentions),
"outbox_object": maybe_object, "outbox_object": maybe_object,
"likes": _merge_faces_from_inbox_object_and_webmentions( "likes": _merge_faces_from_inbox_object_and_webmentions(
likes, likes,
@ -811,6 +812,7 @@ def _filter_webmentions(
not in [ not in [
models.WebmentionType.LIKE, models.WebmentionType.LIKE,
models.WebmentionType.REPOST, models.WebmentionType.REPOST,
models.WebmentionType.REPLY,
] ]
] ]
@ -832,6 +834,30 @@ def _merge_faces_from_inbox_object_and_webmentions(
) )
def _merge_replies(
reply_tree_node: boxes.ReplyTreeNode,
webmentions: list[models.Webmention],
) -> None:
webmention_replies = []
for wm in [
wm for wm in webmentions
if wm.webmention_type == models.WebmentionType.REPLY
]:
if rep := WebmentionReply.from_webmention(wm):
webmention_replies.append(boxes.ReplyTreeNode(
ap_object=None,
wm_reply=rep,
is_requested=False,
children=[],
))
reply_tree_node.children = sorted(
reply_tree_node.children + webmention_replies,
key=lambda node: node.published_at,
)
return reply_tree_node
@app.get("/articles/{short_id}/{slug}") @app.get("/articles/{short_id}/{slug}")
async def article_by_slug( async def article_by_slug(
short_id: str, short_id: str,
@ -865,7 +891,7 @@ async def article_by_slug(
request, request,
"object.html", "object.html",
{ {
"replies_tree": replies_tree, "replies_tree": _merge_replies(replies_tree, webmentions),
"outbox_object": maybe_object, "outbox_object": maybe_object,
"likes": _merge_faces_from_inbox_object_and_webmentions( "likes": _merge_faces_from_inbox_object_and_webmentions(
likes, likes,

View file

@ -33,7 +33,11 @@
{% if replies_tree_node.is_requested %} {% if replies_tree_node.is_requested %}
{{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True) }} {{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True) }}
{% else %} {% else %}
{% if replies_tree_node.wm_reply %}
{{ utils.display_webmention_reply(replies_tree_node.wm_reply) }}
{% else %}
{{ utils.display_object(replies_tree_node.ap_object) }} {{ utils.display_object(replies_tree_node.ap_object) }}
{% endif %}
{% endif %} {% endif %}
{% for child in replies_tree_node.children %} {% for child in replies_tree_node.children %}

View file

@ -441,6 +441,12 @@
{% endblock %} {% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_webmention_reply(wm_reply) %}
{% block display_webmention_reply scoped %}
{{ wm_reply }}
{% endblock %}
{% endmacro %}
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %} {% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
{% block display_object scoped %} {% block display_object scoped %}
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %} {% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}

View file

@ -1,5 +1,6 @@
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
@ -7,7 +8,9 @@ from loguru import logger
from app import media from app import media
from app.models import InboxObject from app.models import InboxObject
from app.models import Webmention from app.models import Webmention
from app.models import WebmentionType
from app.utils.url import make_abs from app.utils.url import make_abs
from app.utils.datetime import parse_isoformat
@dataclass @dataclass
@ -36,7 +39,9 @@ class Face:
try: try:
return cls( return cls(
ap_actor_id=None, ap_actor_id=None,
url=webmention.source, url=(
item["properties"]["url"][0] if item["properties"].get("url") else webmention.source
),
name=item["properties"]["name"][0], name=item["properties"]["name"][0],
picture_url=media.resized_media_url( picture_url=media.resized_media_url(
make_abs( make_abs(
@ -81,3 +86,66 @@ def merge_faces(faces: list[Face]) -> list[Face]:
key=lambda f: f.created_at, key=lambda f: f.created_at,
reverse=True, reverse=True,
)[:10] )[:10]
def _parse_face(webmention: Webmention, items: list[dict[str, Any]]) -> Face | None:
for item in items:
if item["type"][0] == "h-card":
try:
return Face(
ap_actor_id=None,
url=(
items["properties"]["url"][0] if item["properties"].get("url") else webmention.source
),
name=item["properties"]["name"][0],
picture_url=media.resized_media_url(
make_abs(
item["properties"]["photo"][0], webmention.source
), # type: ignore
50,
),
created_at=webmention.created_at, # type: ignore
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break
@dataclass
class WebmentionReply:
face: Face
content: str
url: str
published_at: datetime.datetime
@classmethod
def from_webmention(cls, webmention: Webmention) -> "WebmentionReply":
if webmention.webmention_type != WebmentionType.REPLY:
raise ValueError(f"Unexpected webmention {webmention.id}")
items = webmention.source_microformats.get("items", []) # type: ignore
for item in items:
if item["type"][0] == "h-entry":
try:
face = _parse_face(webmention, item["properties"].get("author", []))
if not face:
logger.info(
"Failed to build WebmentionReply/Face for "
f"webmention id={webmention.id}"
)
break
return cls(
face=face,
content=item["properties"]["content"][0]["html"],
url=item["properties"]["url"][0],
published_at=parse_isoformat(
item["properties"]["published"][0]
).replace(tzinfo=None),
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break