Add full fediverse social service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s

Social blueprint with timeline, compose, search, follow/unfollow,
like/boost interactions, and notifications. Inbox handler extended
for Create/Update/Delete/Accept/Like/Announce with notification
creation. HTMX-powered infinite scroll and interaction buttons.
Nav updated with Timeline, Public, Search, and Notifications links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-22 11:57:24 +00:00
parent 25d21b93af
commit b694d1f4f9
16 changed files with 901 additions and 6 deletions

View File

@@ -2,3 +2,4 @@ from .wellknown.routes import register as register_wellknown_bp
from .actors.routes import register as register_actors_bp
from .identity.routes import register as register_identity_bp
from .auth.routes import register as register_auth_bp
from .social.routes import register as register_social_bp

View File

@@ -301,8 +301,18 @@ def register(url_prefix="/users"):
await _handle_follow(actor_row, body, from_actor_url)
elif activity_type == "Undo":
await _handle_undo(actor_row, body, from_actor_url)
elif activity_type in ("Like", "Announce"):
log.info("Received %s from %s (noted)", activity_type, from_actor_url)
elif activity_type == "Accept":
await _handle_accept(actor_row, body, from_actor_url)
elif activity_type == "Create":
await _handle_create(actor_row, body, from_actor_url)
elif activity_type == "Update":
await _handle_update(actor_row, body, from_actor_url)
elif activity_type == "Delete":
await _handle_delete(actor_row, body, from_actor_url)
elif activity_type == "Like":
await _handle_like(actor_row, body, from_actor_url)
elif activity_type == "Announce":
await _handle_announce(actor_row, body, from_actor_url)
# Mark as processed
item.state = "processed"
@@ -351,6 +361,29 @@ def register(url_prefix="/users"):
follower_acct, actor_row.preferred_username,
)
# Notification
from shared.models.federation import APNotification, RemoteActor
ra = (
await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)
).scalar_one_or_none()
if not ra:
# Store this remote actor
ra_dto = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if ra_dto:
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
if ra:
notif = APNotification(
actor_profile_id=actor_row.id,
notification_type="follow",
from_remote_actor_id=ra.id,
)
g.s.add(notif)
# Send Accept
await _send_accept(actor_row, body, follower_inbox)
@@ -388,6 +421,246 @@ def register(url_prefix="/users"):
else:
log.debug("Undo for %s — not handled", inner_type)
async def _handle_accept(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Accept activity — update outbound follow state."""
inner = body.get("object")
if not inner:
return
inner_type = inner.get("type") if isinstance(inner, dict) else None
if inner_type == "Follow":
await services.federation.accept_follow_response(
g.s, actor_row.preferred_username, from_actor_url,
)
log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username)
async def _handle_create(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Create(Note/Article) — ingest remote post."""
obj = body.get("object")
if not obj or not isinstance(obj, dict):
return
obj_type = obj.get("type", "")
if obj_type not in ("Note", "Article"):
log.debug("Create with type %s — skipping", obj_type)
return
# Get or create remote actor
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
log.warning("Could not resolve remote actor for Create: %s", from_actor_url)
return
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
log.info("Ingested %s from %s", obj_type, from_actor_url)
# Create notification if mentions a local actor
from shared.models.federation import APNotification, APRemotePost, RemoteActor
tags = obj.get("tag", [])
if isinstance(tags, list):
domain = _domain()
for tag in tags:
if not isinstance(tag, dict):
continue
if tag.get("type") != "Mention":
continue
href = tag.get("href", "")
if f"https://{domain}/users/" in href:
mentioned_username = href.rsplit("/", 1)[-1]
mentioned = await services.federation.get_actor_by_username(
g.s, mentioned_username,
)
if mentioned:
# Find the remote post we just created
rp = (await g.s.execute(
select(APRemotePost).where(
APRemotePost.object_id == obj.get("id")
)
)).scalar_one_or_none()
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
notif = APNotification(
actor_profile_id=mentioned.id,
notification_type="mention",
from_remote_actor_id=ra.id if ra else None,
target_remote_post_id=rp.id if rp else None,
)
g.s.add(notif)
# Also check if it's a reply to one of our posts
in_reply_to = obj.get("inReplyTo")
if in_reply_to and f"https://{domain}/users/" in str(in_reply_to):
# It's a reply to one of our local posts
from shared.models.federation import APActivity
local_activity = (await g.s.execute(
select(APActivity).where(
APActivity.activity_id == in_reply_to,
)
)).scalar_one_or_none()
if local_activity:
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
rp = (await g.s.execute(
select(APRemotePost).where(
APRemotePost.object_id == obj.get("id")
)
)).scalar_one_or_none()
notif = APNotification(
actor_profile_id=local_activity.actor_profile_id,
notification_type="reply",
from_remote_actor_id=ra.id if ra else None,
target_remote_post_id=rp.id if rp else None,
)
g.s.add(notif)
async def _handle_update(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Update — re-ingest remote post."""
obj = body.get("object")
if not obj or not isinstance(obj, dict):
return
obj_type = obj.get("type", "")
if obj_type in ("Note", "Article"):
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if remote:
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
log.info("Updated %s from %s", obj_type, from_actor_url)
async def _handle_delete(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Delete — remove remote post."""
obj = body.get("object")
if isinstance(obj, str):
object_id = obj
elif isinstance(obj, dict):
object_id = obj.get("id", "")
else:
return
if object_id:
await services.federation.delete_remote_post(g.s, object_id)
log.info("Deleted remote post %s from %s", object_id, from_actor_url)
async def _handle_like(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process incoming Like — record interaction + notify."""
from shared.models.federation import APInteraction, APNotification, RemoteActor
object_id = body.get("object", "")
if isinstance(object_id, dict):
object_id = object_id.get("id", "")
if not object_id:
return
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
return
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
# Find the local activity this Like targets
from shared.models.federation import APActivity
target = (await g.s.execute(
select(APActivity).where(APActivity.activity_id == object_id)
)).scalar_one_or_none()
if not target:
# Try matching object_data.id
log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id)
return
# Record interaction
interaction = APInteraction(
remote_actor_id=ra.id if ra else None,
post_type="local",
post_id=target.id,
interaction_type="like",
activity_id=body.get("id"),
)
g.s.add(interaction)
# Notification
notif = APNotification(
actor_profile_id=target.actor_profile_id,
notification_type="like",
from_remote_actor_id=ra.id if ra else None,
target_activity_id=target.id,
)
g.s.add(notif)
log.info("Like from %s on activity %s", from_actor_url, object_id)
async def _handle_announce(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process incoming Announce (boost) — record interaction + notify."""
from shared.models.federation import APInteraction, APNotification, RemoteActor
object_id = body.get("object", "")
if isinstance(object_id, dict):
object_id = object_id.get("id", "")
if not object_id:
return
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
return
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
from shared.models.federation import APActivity
target = (await g.s.execute(
select(APActivity).where(APActivity.activity_id == object_id)
)).scalar_one_or_none()
if not target:
log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id)
return
interaction = APInteraction(
remote_actor_id=ra.id if ra else None,
post_type="local",
post_id=target.id,
interaction_type="boost",
activity_id=body.get("id"),
)
g.s.add(interaction)
notif = APNotification(
actor_profile_id=target.actor_profile_id,
notification_type="boost",
from_remote_actor_id=ra.id if ra else None,
target_activity_id=target.id,
)
g.s.add(notif)
log.info("Announce from %s on activity %s", from_actor_url, object_id)
@bp.get("/<username>/followers")
async def followers(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
@@ -431,6 +704,7 @@ def register(url_prefix="/users"):
domain = _domain()
collection_id = f"https://{domain}/users/{username}/following"
following_list, total = await services.federation.get_following(g.s, username)
page_param = request.args.get("page")
if not page_param:
@@ -439,7 +713,7 @@ def register(url_prefix="/users"):
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": 0,
"totalItems": total,
"first": f"{collection_id}?page=1",
}),
content_type=AP_CONTENT_TYPE,
@@ -451,8 +725,8 @@ def register(url_prefix="/users"):
"type": "OrderedCollectionPage",
"id": f"{collection_id}?page=1",
"partOf": collection_id,
"totalItems": 0,
"orderedItems": [],
"totalItems": total,
"orderedItems": [f.actor_url for f in following_list],
}),
content_type=AP_CONTENT_TYPE,
)

0
bp/social/__init__.py Normal file
View File

302
bp/social/routes.py Normal file
View File

@@ -0,0 +1,302 @@
"""Social fediverse routes: timeline, compose, search, follow, interactions, notifications."""
from __future__ import annotations
import logging
from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
from shared.services.registry import services
log = logging.getLogger(__name__)
def _require_actor():
"""Return actor context or abort 403."""
actor = g.get("ctx", {}).get("actor") if hasattr(g, "ctx") else None
if not actor:
actor = getattr(g, "_social_actor", None)
if not actor:
abort(403, "You need to choose a federation username first")
return actor
def register(url_prefix="/social"):
bp = Blueprint("social", __name__, url_prefix=url_prefix)
@bp.before_request
async def load_actor():
"""Load actor profile for authenticated users."""
if g.get("user"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor
# -- Timeline -------------------------------------------------------------
@bp.get("/")
async def home_timeline():
if not g.get("user"):
return redirect(url_for("auth.login_form"))
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="home",
actor=actor,
)
@bp.get("/timeline")
async def home_timeline_page():
actor = _require_actor()
before_str = request.args.get("before")
before = None
if before_str:
try:
before = datetime.fromisoformat(before_str)
except ValueError:
pass
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="home",
actor=actor,
)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="public",
actor=actor,
)
@bp.get("/public/timeline")
async def public_timeline_page():
before_str = request.args.get("before")
before = None
if before_str:
try:
before = datetime.fromisoformat(before_str)
except ValueError:
pass
items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="public",
actor=actor,
)
# -- Compose --------------------------------------------------------------
@bp.get("/compose")
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
return await render_template(
"federation/compose.html",
actor=actor,
reply_to=reply_to,
)
@bp.post("/compose")
async def compose_submit():
actor = _require_actor()
form = await request.form
content = form.get("content", "").strip()
if not content:
return redirect(url_for("social.compose_form"))
visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None
await services.federation.create_local_post(
g.s, actor.id,
content=content,
visibility=visibility,
in_reply_to=in_reply_to,
)
return redirect(url_for("social.home_timeline"))
@bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int):
actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.home_timeline"))
# -- Search + Follow ------------------------------------------------------
@bp.get("/search")
async def search():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
result = None
if query:
result = await services.federation.search_remote_actor(g.s, query)
return await render_template(
"federation/search.html",
query=query,
result=result,
actor=actor,
)
@bp.post("/follow")
async def follow():
actor = _require_actor()
form = await request.form
remote_actor_url = form.get("actor_url", "")
if remote_actor_url:
await services.federation.send_follow(
g.s, actor.preferred_username, remote_actor_url,
)
return redirect(url_for("social.search"))
@bp.post("/unfollow")
async def unfollow():
actor = _require_actor()
form = await request.form
remote_actor_url = form.get("actor_url", "")
if remote_actor_url:
await services.federation.unfollow(
g.s, actor.preferred_username, remote_actor_url,
)
return redirect(url_for("social.search"))
# -- Interactions ---------------------------------------------------------
@bp.post("/like")
async def like():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.like_post(g.s, actor.id, object_id, author_inbox)
# Return updated buttons for HTMX
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unlike")
async def unlike():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.unlike_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/boost")
async def boost():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.boost_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unboost")
async def unboost():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.unboost_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
async def _interaction_buttons_response(actor, object_id, author_inbox):
"""Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction, APRemotePost, APActivity
from sqlalchemy import select
from shared.services.federation_impl import SqlFederationService
svc = services.federation
post_type, post_id = await svc._resolve_post(g.s, object_id)
like_count = 0
boost_count = 0
liked_by_me = False
boosted_by_me = False
if post_type:
from sqlalchemy import func as sa_func
like_count = (await g.s.execute(
select(sa_func.count(APInteraction.id)).where(
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "like",
)
)).scalar() or 0
boost_count = (await g.s.execute(
select(sa_func.count(APInteraction.id)).where(
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "boost",
)
)).scalar() or 0
liked_by_me = bool((await g.s.execute(
select(APInteraction.id).where(
APInteraction.actor_profile_id == actor.id,
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "like",
).limit(1)
)).scalar())
boosted_by_me = bool((await g.s.execute(
select(APInteraction.id).where(
APInteraction.actor_profile_id == actor.id,
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "boost",
).limit(1)
)).scalar())
return await render_template(
"federation/_interaction_buttons.html",
item_object_id=object_id,
item_author_inbox=author_inbox,
like_count=like_count,
boost_count=boost_count,
liked_by_me=liked_by_me,
boosted_by_me=boosted_by_me,
actor=actor,
)
# -- Notifications --------------------------------------------------------
@bp.get("/notifications")
async def notifications():
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
return await render_template(
"federation/notifications.html",
notifications=items,
actor=actor,
)
@bp.get("/notifications/count")
async def notification_count():
actor = getattr(g, "_social_actor", None)
if not actor:
return Response("0", content_type="text/plain")
count = await services.federation.unread_notification_count(g.s, actor.id)
if count > 0:
return Response(
f'<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5">{count}</span>',
content_type="text/html",
)
return Response("", content_type="text/html")
@bp.post("/notifications/read")
async def mark_read():
actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.notifications"))
return bp