"""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). All rendering uses s-expressions (no Jinja templates). """ from __future__ import annotations import logging from datetime import datetime from quart import Blueprint, request, g, redirect, url_for, abort, 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 async def _render_social_page(content: str, actor=None, title: str = "Social"): """Render a full social page or OOB response depending on request type.""" from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.page import get_template_context from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response from shared.infrastructure.ap_social_sx import ( _social_full_headers, _social_oob_headers, ) tctx = await get_template_context() kw = {"actor": actor} if is_htmx_request(): oob_headers = await _social_oob_headers(tctx, **kw) return sx_response(await oob_page_sx( oobs=oob_headers, content=content, )) else: header_rows = await _social_full_headers(tctx, **kw) return await full_page_sx(tctx, header_rows=header_rows, content=content) # -- Index ---------------------------------------------------------------- @bp.get("/") async def index(): actor = getattr(g, "_social_actor", None) from shared.infrastructure.ap_social_sx import social_index_content_sx content = social_index_content_sx(actor) return await _render_social_page(content, actor, title="Social") # -- 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} from shared.infrastructure.ap_social_sx import social_search_content_sx content = social_search_content_sx(query, actors, total, 1, followed_urls, actor) return await _render_social_page(content, actor, title="Search") @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} from shared.infrastructure.ap_social_sx import search_results_sx from shared.sx.helpers import sx_response content = search_results_sx(actors, total, page, query, followed_urls, actor) return sx_response(content) # -- 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.""" 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" from shared.infrastructure.ap_social_sx import actor_list_items_sx from shared.sx.helpers import sx_response content = actor_list_items_sx( [remote_dto], 0, 1, list_type, followed_urls, actor, ) return sx_response(content) # -- 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} from shared.infrastructure.ap_social_sx import social_followers_content_sx content = social_followers_content_sx(actors, total, 1, followed_urls, actor) return await _render_social_page(content, actor, title="Followers") @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} from shared.infrastructure.ap_social_sx import actor_list_items_sx from shared.sx.helpers import sx_response content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor) return sx_response(content) # -- Following ------------------------------------------------------------ @bp.get("/following") async def following_list(): actor = _require_actor() actors, total = await services.federation.get_following( g._ap_s, actor.preferred_username, ) from shared.infrastructure.ap_social_sx import social_following_content_sx content = social_following_content_sx(actors, total, 1, actor) return await _render_social_page(content, actor, title="Following") @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, ) from shared.infrastructure.ap_social_sx import actor_list_items_sx from shared.sx.helpers import sx_response content = actor_list_items_sx(actors, total, page, "following", set(), actor) return sx_response(content) # -- 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 from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor) return await _render_social_page(content, 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, ) from shared.infrastructure.ap_social_sx import timeline_items_sx from shared.sx.helpers import sx_response content = timeline_items_sx(items, "actor", id, actor) return sx_response(content) return bp