forked from forks/microblog.pub
Display Webmention as replies when applicable
This commit is contained in:
parent
ae8029cd22
commit
120f92a9ed
5 changed files with 124 additions and 6 deletions
18
app/boxes.py
18
app/boxes.py
|
@ -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=[],
|
||||||
|
|
30
app/main.py
30
app/main.py
|
@ -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,
|
||||||
|
|
|
@ -32,9 +32,13 @@
|
||||||
|
|
||||||
{% 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 %}
|
||||||
|
{% if replies_tree_node.wm_reply %}
|
||||||
|
{{ utils.display_webmention_reply(replies_tree_node.wm_reply) }}
|
||||||
{% else %}
|
{% 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 %}
|
||||||
{{ display_replies_tree(child) }}
|
{{ display_replies_tree(child) }}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue