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) %} +
+ {% if actor %} + + {% else %} + + {% endif %} +
+ {% 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 list_type == "following" and 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 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}