"""Per-app AP social blueprint: search, follow/unfollow, followers, following, actor timeline. Lightweight social UI for blog/market/events. Federation keeps the full social hub (timeline, compose, notifications, interactions). """ 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 create_ap_social_blueprint(app_name: str) -> Blueprint: """Create a per-app social blueprint scoped to *app_name*.""" bp = Blueprint("ap_social", __name__, url_prefix="/social") # ------------------------------------------------------------------ # Federation session — AP tables live in db_federation. # ------------------------------------------------------------------ from shared.db.session import needs_federation_session, create_federation_session @bp.before_request async def _open_ap_session(): if needs_federation_session(): sess = create_federation_session() g._ap_s = sess g._ap_tx = await sess.begin() g._ap_own = True else: g._ap_s = g.s g._ap_own = False @bp.after_request async def _commit_ap_session(response): if getattr(g, "_ap_own", False): if 200 <= response.status_code < 400: try: await g._ap_tx.commit() except Exception: try: await g._ap_tx.rollback() except Exception: pass return response @bp.teardown_request async def _close_ap_session(exc): if getattr(g, "_ap_own", False): s = getattr(g, "_ap_s", None) if s: if exc is not None or s.in_transaction(): tx = getattr(g, "_ap_tx", None) if tx and tx.is_active: try: await tx.rollback() except Exception: pass try: await s.close() except Exception: pass @bp.before_request async def load_actor(): if g.get("user"): actor = await services.federation.get_actor_by_user_id(g._ap_s, g.user.id) g._social_actor = actor def _require_actor(): actor = getattr(g, "_social_actor", None) if not actor: abort(403, "You need to choose a federation username first") return actor # -- Index ---------------------------------------------------------------- @bp.get("/") async def index(): actor = getattr(g, "_social_actor", None) return await render_template( "social/index.html", actor=actor, ) # -- Search --------------------------------------------------------------- @bp.get("/search") async def search(): actor = getattr(g, "_social_actor", None) query = request.args.get("q", "").strip() actors = [] total = 0 followed_urls: set[str] = set() if query: actors, total = await services.federation.search_actors(g._ap_s, query) if actor: following, _ = await services.federation.get_following( g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} return await render_template( "social/search.html", query=query, actors=actors, total=total, page=1, followed_urls=followed_urls, actor=actor, ) @bp.get("/search/page") async def search_page(): actor = getattr(g, "_social_actor", None) query = request.args.get("q", "").strip() page = request.args.get("page", 1, type=int) actors = [] total = 0 followed_urls: set[str] = set() if query: actors, total = await services.federation.search_actors( g._ap_s, query, page=page, ) if actor: following, _ = await services.federation.get_following( g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} return await render_template( "social/_search_results.html", actors=actors, total=total, page=page, query=query, followed_urls=followed_urls, actor=actor, ) # -- Follow / Unfollow ---------------------------------------------------- @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._ap_s, actor.preferred_username, remote_actor_url, ) if request.headers.get("SX-Request") or request.headers.get("HX-Request"): return await _actor_card_response(actor, remote_actor_url, is_followed=True) return redirect(request.referrer or url_for("ap_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._ap_s, actor.preferred_username, remote_actor_url, ) if request.headers.get("SX-Request") or request.headers.get("HX-Request"): return await _actor_card_response(actor, remote_actor_url, is_followed=False) return redirect(request.referrer or url_for("ap_social.search")) async def _actor_card_response(actor, remote_actor_url, is_followed): """Re-render a single actor card after follow/unfollow via HTMX.""" remote_dto = await services.federation.get_or_fetch_remote_actor( g._ap_s, remote_actor_url, ) if not remote_dto: return Response("", status=200) followed_urls = {remote_actor_url} if is_followed else set() referer = request.referrer or "" if "/followers" in referer: list_type = "followers" else: list_type = "following" return await render_template( "social/_actor_list_items.html", actors=[remote_dto], total=0, page=1, list_type=list_type, followed_urls=followed_urls, actor=actor, ) # -- Followers ------------------------------------------------------------ @bp.get("/followers") async def followers_list(): actor = _require_actor() actors, total = await services.federation.get_followers_paginated( g._ap_s, actor.preferred_username, app_domain=app_name, ) following, _ = await services.federation.get_following( g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} return await render_template( "social/followers.html", actors=actors, total=total, page=1, followed_urls=followed_urls, actor=actor, ) @bp.get("/followers/page") async def followers_list_page(): actor = _require_actor() page = request.args.get("page", 1, type=int) actors, total = await services.federation.get_followers_paginated( g._ap_s, actor.preferred_username, page=page, app_domain=app_name, ) following, _ = await services.federation.get_following( g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} return await render_template( "social/_actor_list_items.html", actors=actors, total=total, page=page, list_type="followers", followed_urls=followed_urls, actor=actor, ) # -- Following ------------------------------------------------------------ @bp.get("/following") async def following_list(): actor = _require_actor() actors, total = await services.federation.get_following( g._ap_s, actor.preferred_username, ) return await render_template( "social/following.html", actors=actors, total=total, page=1, actor=actor, ) @bp.get("/following/page") async def following_list_page(): actor = _require_actor() page = request.args.get("page", 1, type=int) actors, total = await services.federation.get_following( g._ap_s, actor.preferred_username, page=page, ) return await render_template( "social/_actor_list_items.html", actors=actors, total=total, page=page, list_type="following", followed_urls=set(), actor=actor, ) # -- Actor timeline ------------------------------------------------------- @bp.get("/actor/") async def actor_timeline(id: int): actor = getattr(g, "_social_actor", None) from shared.models.federation import RemoteActor from sqlalchemy import select as sa_select remote = ( await g._ap_s.execute( sa_select(RemoteActor).where(RemoteActor.id == id) ) ).scalar_one_or_none() if not remote: abort(404) from shared.services.federation_impl import _remote_actor_to_dto remote_dto = _remote_actor_to_dto(remote) items = await services.federation.get_actor_timeline(g._ap_s, id) is_following = False if actor: from shared.models.federation import APFollowing existing = ( await g._ap_s.execute( sa_select(APFollowing).where( APFollowing.actor_profile_id == actor.id, APFollowing.remote_actor_id == id, ) ) ).scalar_one_or_none() is_following = existing is not None return await render_template( "social/actor_timeline.html", remote_actor=remote_dto, items=items, is_following=is_following, actor=actor, ) @bp.get("/actor//timeline") async def actor_timeline_page(id: int): actor = getattr(g, "_social_actor", None) 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_actor_timeline( g._ap_s, id, before=before, ) return await render_template( "social/_timeline_items.html", items=items, timeline_type="actor", actor_id=id, actor=actor, ) return bp