diff --git a/shared/browser/templates/_types/social_lite/header/_header.html b/shared/browser/templates/_types/social_lite/header/_header.html
new file mode 100644
index 0000000..6e7b337
--- /dev/null
+++ b/shared/browser/templates/_types/social_lite/header/_header.html
@@ -0,0 +1,42 @@
+{% import 'macros/links.html' as links %}
+{% macro header_row(oob=False) %}
+ {% call links.menu_row(id='social-lite-row', oob=oob) %}
+
+ {% endcall %}
+{% endmacro %}
diff --git a/shared/browser/templates/_types/social_lite/index.html b/shared/browser/templates/_types/social_lite/index.html
new file mode 100644
index 0000000..dc3e25d
--- /dev/null
+++ b/shared/browser/templates/_types/social_lite/index.html
@@ -0,0 +1,10 @@
+{% extends '_types/root/_index.html' %}
+{% block meta %}{% endblock %}
+{% block root_header_child %}
+ {% from '_types/root/_n/macros.html' import index_row with context %}
+ {% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
+ {% endcall %}
+{% endblock %}
+{% block content %}
+ {% block social_content %}{% endblock %}
+{% endblock %}
diff --git a/shared/browser/templates/social/_actor_list_items.html b/shared/browser/templates/social/_actor_list_items.html
new file mode 100644
index 0000000..92ac174
--- /dev/null
+++ b/shared/browser/templates/social/_actor_list_items.html
@@ -0,0 +1,63 @@
+{% for a in actors %}
+
+ {% if a.icon_url %}
+
+ {% else %}
+
+ {{ (a.display_name or a.preferred_username)[0] | upper }}
+
+ {% endif %}
+
+
+
+ {% if actor %}
+
+ {% if list_type == "following" or a.actor_url in (followed_urls or []) %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+
+{% endfor %}
+
+{% if actors | length >= 20 %}
+
+
+{% endif %}
diff --git a/shared/browser/templates/social/_post_card.html b/shared/browser/templates/social/_post_card.html
new file mode 100644
index 0000000..5556652
--- /dev/null
+++ b/shared/browser/templates/social/_post_card.html
@@ -0,0 +1,53 @@
+
+ {% if item.boosted_by %}
+
+ Boosted by {{ item.boosted_by }}
+
+ {% endif %}
+
+
+ {% if item.actor_icon %}
+

+ {% else %}
+
+ {{ item.actor_name[0] | upper if item.actor_name else '?' }}
+
+ {% endif %}
+
+
+
+ {{ item.actor_name }}
+
+ @{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
+
+
+ {% if item.published %}
+ {{ item.published.strftime('%b %d, %H:%M') }}
+ {% endif %}
+
+
+
+ {% if item.summary %}
+
+ CW: {{ item.summary }}
+ {{ item.content | safe }}
+
+ {% else %}
+
{{ item.content | safe }}
+ {% endif %}
+
+
+ {% if item.url and item.post_type == "remote" %}
+
+ original
+
+ {% endif %}
+ {% if item.object_id %}
+
+ View on Hub
+
+ {% endif %}
+
+
+
+
diff --git a/shared/browser/templates/social/_search_results.html b/shared/browser/templates/social/_search_results.html
new file mode 100644
index 0000000..822d4b8
--- /dev/null
+++ b/shared/browser/templates/social/_search_results.html
@@ -0,0 +1,61 @@
+{% for a in actors %}
+
+ {% if a.icon_url %}
+
+ {% else %}
+
+ {{ (a.display_name or a.preferred_username)[0] | upper }}
+
+ {% endif %}
+
+
+ {% if a.id %}
+
+ {{ a.display_name or a.preferred_username }}
+
+ {% else %}
+
{{ a.display_name or a.preferred_username }}
+ {% endif %}
+
@{{ a.preferred_username }}@{{ a.domain }}
+ {% if a.summary %}
+
{{ a.summary | striptags }}
+ {% endif %}
+
+
+ {% if actor %}
+
+ {% if a.actor_url in (followed_urls or []) %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+
+{% endfor %}
+
+{% if actors | length >= 20 %}
+
+
+{% endif %}
diff --git a/shared/browser/templates/social/_timeline_items.html b/shared/browser/templates/social/_timeline_items.html
new file mode 100644
index 0000000..446ae3c
--- /dev/null
+++ b/shared/browser/templates/social/_timeline_items.html
@@ -0,0 +1,13 @@
+{% for item in items %}
+ {% include "social/_post_card.html" %}
+{% endfor %}
+
+{% if items %}
+ {% set last = items[-1] %}
+ {% if timeline_type == "actor" %}
+
+
+ {% endif %}
+{% endif %}
diff --git a/shared/browser/templates/social/actor_timeline.html b/shared/browser/templates/social/actor_timeline.html
new file mode 100644
index 0000000..ed34232
--- /dev/null
+++ b/shared/browser/templates/social/actor_timeline.html
@@ -0,0 +1,53 @@
+{% extends "_types/social_lite/index.html" %}
+
+{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
+
+{% block social_content %}
+
+
+ {% if remote_actor.icon_url %}
+

+ {% else %}
+
+ {{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
+
+ {% endif %}
+
+
+
{{ remote_actor.display_name or remote_actor.preferred_username }}
+
@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}
+ {% if remote_actor.summary %}
+
{{ remote_actor.summary | safe }}
+ {% endif %}
+
+
+ {% if actor %}
+
+ {% if is_following %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+ {% set timeline_type = "actor" %}
+ {% set actor_id = remote_actor.id %}
+ {% include "social/_timeline_items.html" %}
+
+{% endblock %}
diff --git a/shared/browser/templates/social/followers.html b/shared/browser/templates/social/followers.html
new file mode 100644
index 0000000..9421537
--- /dev/null
+++ b/shared/browser/templates/social/followers.html
@@ -0,0 +1,12 @@
+{% extends "_types/social_lite/index.html" %}
+
+{% block title %}Followers — Rose Ash{% endblock %}
+
+{% block social_content %}
+Followers ({{ total }})
+
+
+ {% set list_type = "followers" %}
+ {% include "social/_actor_list_items.html" %}
+
+{% endblock %}
diff --git a/shared/browser/templates/social/following.html b/shared/browser/templates/social/following.html
new file mode 100644
index 0000000..5643b94
--- /dev/null
+++ b/shared/browser/templates/social/following.html
@@ -0,0 +1,13 @@
+{% extends "_types/social_lite/index.html" %}
+
+{% block title %}Following — Rose Ash{% endblock %}
+
+{% block social_content %}
+Following ({{ total }})
+
+
+ {% set list_type = "following" %}
+ {% set followed_urls = [] %}
+ {% include "social/_actor_list_items.html" %}
+
+{% endblock %}
diff --git a/shared/browser/templates/social/search.html b/shared/browser/templates/social/search.html
new file mode 100644
index 0000000..c3a94cc
--- /dev/null
+++ b/shared/browser/templates/social/search.html
@@ -0,0 +1,32 @@
+{% extends "_types/social_lite/index.html" %}
+
+{% block title %}Search — Rose Ash{% endblock %}
+
+{% block social_content %}
+Search
+
+
+
+{% if query and total %}
+ {{ total }} result{{ 's' if total != 1 }} for {{ query }}
+{% elif query %}
+ No results found for {{ query }}
+{% endif %}
+
+
+ {% include "social/_search_results.html" %}
+
+{% endblock %}
diff --git a/shared/contracts/protocols.py b/shared/contracts/protocols.py
index e806b8a..02d1262 100644
--- a/shared/contracts/protocols.py
+++ b/shared/contracts/protocols.py
@@ -243,6 +243,7 @@ class FederationService(Protocol):
async def get_followers_paginated(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
+ app_domain: str | None = None,
) -> tuple[list[RemoteActorDTO], int]: ...
async def add_follower(
diff --git a/shared/infrastructure/ap_social.py b/shared/infrastructure/ap_social.py
new file mode 100644
index 0000000..795c6e8
--- /dev/null
+++ b/shared/infrastructure/ap_social.py
@@ -0,0 +1,271 @@
+"""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")
+
+ @bp.before_request
+ async def load_actor():
+ if g.get("user"):
+ actor = await services.federation.get_actor_by_user_id(g.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
+
+ # -- 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.s, query)
+ if actor:
+ following, _ = await services.federation.get_following(
+ g.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.s, query, page=page,
+ )
+ if actor:
+ following, _ = await services.federation.get_following(
+ g.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.s, actor.preferred_username, remote_actor_url,
+ )
+ if 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.s, actor.preferred_username, remote_actor_url,
+ )
+ if 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.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.s, actor.preferred_username, app_domain=app_name,
+ )
+ following, _ = await services.federation.get_following(
+ g.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.s, actor.preferred_username, page=page, app_domain=app_name,
+ )
+ following, _ = await services.federation.get_following(
+ g.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.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.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.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.s, id)
+ is_following = False
+ if actor:
+ from shared.models.federation import APFollowing
+ existing = (
+ await g.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.s, id, before=before,
+ )
+ return await render_template(
+ "social/_timeline_items.html",
+ items=items,
+ timeline_type="actor",
+ actor_id=id,
+ actor=actor,
+ )
+
+ return bp
diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py
index 279aa78..17659ce 100644
--- a/shared/infrastructure/factory.py
+++ b/shared/infrastructure/factory.py
@@ -111,6 +111,11 @@ def create_base_app(
from shared.infrastructure.activitypub import create_activitypub_blueprint
app.register_blueprint(create_activitypub_blueprint(name))
+ # Auto-register per-app social blueprint (not federation — it has its own)
+ if name in AP_APPS and name != "federation":
+ from shared.infrastructure.ap_social import create_ap_social_blueprint
+ app.register_blueprint(create_ap_social_blueprint(name))
+
# --- device id (all apps, including account) ---
_did_cookie = f"{name}_did"
diff --git a/shared/services/federation_impl.py b/shared/services/federation_impl.py
index fa33d7d..3ff199c 100644
--- a/shared/services/federation_impl.py
+++ b/shared/services/federation_impl.py
@@ -403,6 +403,7 @@ class SqlFederationService:
async def get_followers_paginated(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
+ app_domain: str | None = None,
) -> tuple[list[RemoteActorDTO], int]:
actor = (
await session.execute(
@@ -412,11 +413,13 @@ class SqlFederationService:
if actor is None:
return [], 0
+ filters = [APFollower.actor_profile_id == actor.id]
+ if app_domain is not None:
+ filters.append(APFollower.app_domain == app_domain)
+
total = (
await session.execute(
- select(func.count(APFollower.id)).where(
- APFollower.actor_profile_id == actor.id,
- )
+ select(func.count(APFollower.id)).where(*filters)
)
).scalar() or 0
@@ -424,7 +427,7 @@ class SqlFederationService:
followers = (
await session.execute(
select(APFollower)
- .where(APFollower.actor_profile_id == actor.id)
+ .where(*filters)
.order_by(APFollower.created_at.desc())
.limit(per_page)
.offset(offset)
diff --git a/shared/services/stubs.py b/shared/services/stubs.py
index 748423c..80aa804 100644
--- a/shared/services/stubs.py
+++ b/shared/services/stubs.py
@@ -28,5 +28,15 @@ class StubFederationService:
async def count_activities_for_source(self, session, source_type, source_id, *, activity_type):
return 0
+ async def get_followers_paginated(self, session, username,
+ page=1, per_page=20, app_domain=None):
+ return [], 0
+
+ async def get_following(self, session, username, page=1, per_page=20):
+ return [], 0
+
+ async def search_actors(self, session, query, page=1, limit=20):
+ return [], 0
+
async def get_stats(self, session):
return {"actors": 0, "activities": 0, "followers": 0}