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

@@ -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,
)