diff --git a/app/boxes.py b/app/boxes.py index f258934..ce0c7a9 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -1,4 +1,5 @@ """Actions related to the AP inbox/outbox.""" +import datetime import uuid from collections import defaultdict 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 parse_isoformat from app.utils.text import slugify +from app.utils.facepile import WebmentionReply AnyboxObject = models.InboxObject | models.OutboxObject @@ -2581,11 +2583,21 @@ async def fetch_actor_collection(db_session: AsyncSession, url: str) -> list[Act @dataclass class ReplyTreeNode: - ap_object: AnyboxObject + ap_object: AnyboxObject | None + wm_reply: WebmentionReply | None children: list["ReplyTreeNode"] is_requested: 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( db_session: AsyncSession, @@ -2659,6 +2671,7 @@ async def get_replies_tree( for child in index.get(node.ap_object.ap_id, []): # type: ignore child_node = ReplyTreeNode( ap_object=child, + wm_reply=None, is_requested=child.ap_id == requested_object.ap_id, # type: ignore children=[], ) @@ -2667,7 +2680,7 @@ async def get_replies_tree( return sorted( 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: @@ -2680,6 +2693,7 @@ async def get_replies_tree( root_node = ReplyTreeNode( ap_object=root_ap_object, + wm_reply=None, is_root=True, is_requested=root_ap_object.ap_id == requested_object.ap_id, children=[], diff --git a/app/main.py b/app/main.py index 814c650..6e26891 100644 --- a/app/main.py +++ b/app/main.py @@ -75,6 +75,7 @@ from app.utils import pagination from app.utils.emoji import EMOJIS_BY_NAME from app.utils.facepile import Face from app.utils.facepile import merge_faces +from app.utils.facepile import WebmentionReply from app.utils.highlight import HIGHLIGHT_CSS_HASH from app.utils.url import check_url from app.webfinger import get_remote_follow_template @@ -784,7 +785,7 @@ async def outbox_by_public_id( request, "object.html", { - "replies_tree": replies_tree, + "replies_tree": _merge_replies(replies_tree, webmentions), "outbox_object": maybe_object, "likes": _merge_faces_from_inbox_object_and_webmentions( likes, @@ -811,6 +812,7 @@ def _filter_webmentions( not in [ models.WebmentionType.LIKE, 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}") async def article_by_slug( short_id: str, @@ -865,7 +891,7 @@ async def article_by_slug( request, "object.html", { - "replies_tree": replies_tree, + "replies_tree": _merge_replies(replies_tree, webmentions), "outbox_object": maybe_object, "likes": _merge_faces_from_inbox_object_and_webmentions( likes, diff --git a/app/templates/object.html b/app/templates/object.html index b6cfa28..12e2ff5 100644 --- a/app/templates/object.html +++ b/app/templates/object.html @@ -33,7 +33,11 @@ {% 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) }} {% else %} - {{ utils.display_object(replies_tree_node.ap_object) }} + {% if replies_tree_node.wm_reply %} + {{ utils.display_webmention_reply(replies_tree_node.wm_reply) }} + {% else %} + {{ utils.display_object(replies_tree_node.ap_object) }} + {% endif %} {% endif %} {% for child in replies_tree_node.children %} diff --git a/app/templates/utils.html b/app/templates/utils.html index 55621a2..bfe3c3a 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -441,6 +441,12 @@ {% endblock %} {% 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) %} {% block display_object scoped %} {% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %} diff --git a/app/utils/facepile.py b/app/utils/facepile.py index 238b9bf..5aa56a6 100644 --- a/app/utils/facepile.py +++ b/app/utils/facepile.py @@ -1,5 +1,6 @@ import datetime from dataclasses import dataclass +from typing import Any from typing import Optional from loguru import logger @@ -7,7 +8,9 @@ from loguru import logger from app import media from app.models import InboxObject from app.models import Webmention +from app.models import WebmentionType from app.utils.url import make_abs +from app.utils.datetime import parse_isoformat @dataclass @@ -36,7 +39,9 @@ class Face: try: return cls( 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], picture_url=media.resized_media_url( make_abs( @@ -81,3 +86,66 @@ def merge_faces(faces: list[Face]) -> list[Face]: key=lambda f: f.created_at, reverse=True, )[: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