From 27e86c580b5ef3d064f7387d81f060d69f573ba3 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 22 Feb 2026 13:42:16 +0000 Subject: [PATCH] Add following/followers lists and per-actor timeline pages - 6 new routes: following list, followers list, actor timeline (each with HTMX infinite-scroll page endpoint) - 4 new templates: following.html, followers.html, _actor_list_items.html, actor_timeline.html - Nav links for Following/Followers in base.html - Follow/unfollow redirects back to referrer page - Timeline items template handles actor timeline type - Update shared submodule Co-Authored-By: Claude Opus 4.6 --- bp/social/routes.py | 136 +++++++++++++++++++- templates/federation/_actor_list_items.html | 60 +++++++++ templates/federation/_timeline_items.html | 15 ++- templates/federation/actor_timeline.html | 53 ++++++++ templates/federation/base.html | 2 + templates/federation/followers.html | 12 ++ templates/federation/following.html | 13 ++ 7 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 templates/federation/_actor_list_items.html create mode 100644 templates/federation/actor_timeline.html create mode 100644 templates/federation/followers.html create mode 100644 templates/federation/following.html diff --git a/bp/social/routes.py b/bp/social/routes.py index 0c3b75c..32a36a0 100644 --- a/bp/social/routes.py +++ b/bp/social/routes.py @@ -157,7 +157,7 @@ def register(url_prefix="/social"): await services.federation.send_follow( g.s, actor.preferred_username, remote_actor_url, ) - return redirect(url_for("social.search")) + return redirect(request.referrer or url_for("social.search")) @bp.post("/unfollow") async def unfollow(): @@ -168,7 +168,7 @@ def register(url_prefix="/social"): await services.federation.unfollow( g.s, actor.preferred_username, remote_actor_url, ) - return redirect(url_for("social.search")) + return redirect(request.referrer or url_for("social.search")) # -- Interactions --------------------------------------------------------- @@ -267,6 +267,138 @@ def register(url_prefix="/social"): actor=actor, ) + # -- Following / Followers ------------------------------------------------ + + @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( + "federation/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( + "federation/_actor_list_items.html", + actors=actors, + total=total, + page=page, + list_type="following", + followed_urls=set(), + actor=actor, + ) + + @bp.get("/followers") + async def followers_list(): + actor = _require_actor() + actors, total = await services.federation.get_followers_paginated( + g.s, actor.preferred_username, + ) + # Build set of followed actor URLs to show Follow Back vs Unfollow + 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( + "federation/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, + ) + 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( + "federation/_actor_list_items.html", + actors=actors, + total=total, + page=page, + list_type="followers", + followed_urls=followed_urls, + actor=actor, + ) + + @bp.get("/actor/") + async def actor_timeline(id: int): + actor = getattr(g, "_social_actor", None) + # Get remote actor info + 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) + # Check if we follow this actor + 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( + "federation/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( + "federation/_timeline_items.html", + items=items, + timeline_type="actor", + actor_id=id, + actor=actor, + ) + # -- Notifications -------------------------------------------------------- @bp.get("/notifications") diff --git a/templates/federation/_actor_list_items.html b/templates/federation/_actor_list_items.html new file mode 100644 index 0000000..b294721 --- /dev/null +++ b/templates/federation/_actor_list_items.html @@ -0,0 +1,60 @@ +{% for a in actors %} +
+ {% if a.icon_url %} + + {% else %} +
+ {{ (a.display_name or a.preferred_username)[0] | upper }} +
+ {% endif %} + +
+ + {{ a.display_name or a.preferred_username }} + +
@{{ a.preferred_username }}@{{ a.domain }}
+ {% if a.summary %} +
{{ a.summary | striptags }}
+ {% endif %} +
+ + {% if actor %} +
+ {% if list_type == "following" %} +
+ + + +
+ {% elif list_type == "followers" %} + {% if a.actor_url in followed_urls %} +
+ + + +
+ {% else %} +
+ + + +
+ {% endif %} + {% endif %} +
+ {% endif %} +
+{% endfor %} + +{% if actors | length >= 20 %} +
+
+{% endif %} diff --git a/templates/federation/_timeline_items.html b/templates/federation/_timeline_items.html index 60fe317..c004743 100644 --- a/templates/federation/_timeline_items.html +++ b/templates/federation/_timeline_items.html @@ -4,8 +4,15 @@ {% if items %} {% set last = items[-1] %} -
-
+ {% if timeline_type == "actor" %} +
+
+ {% else %} +
+
+ {% endif %} {% endif %} diff --git a/templates/federation/actor_timeline.html b/templates/federation/actor_timeline.html new file mode 100644 index 0000000..f7023d5 --- /dev/null +++ b/templates/federation/actor_timeline.html @@ -0,0 +1,53 @@ +{% extends "federation/base.html" %} + +{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %} + +{% block 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 "federation/_timeline_items.html" %} +
+{% endblock %} diff --git a/templates/federation/base.html b/templates/federation/base.html index b8d67b9..344bffe 100644 --- a/templates/federation/base.html +++ b/templates/federation/base.html @@ -16,6 +16,8 @@ {% if actor %} Timeline Public + Following + Followers Search Notifications diff --git a/templates/federation/followers.html b/templates/federation/followers.html new file mode 100644 index 0000000..c436064 --- /dev/null +++ b/templates/federation/followers.html @@ -0,0 +1,12 @@ +{% extends "federation/base.html" %} + +{% block title %}Followers — Rose Ash{% endblock %} + +{% block content %} +

Followers ({{ total }})

+ +
+ {% set list_type = "followers" %} + {% include "federation/_actor_list_items.html" %} +
+{% endblock %} diff --git a/templates/federation/following.html b/templates/federation/following.html new file mode 100644 index 0000000..90ed32b --- /dev/null +++ b/templates/federation/following.html @@ -0,0 +1,13 @@ +{% extends "federation/base.html" %} + +{% block title %}Following — Rose Ash{% endblock %} + +{% block content %} +

Following ({{ total }})

+ +
+ {% set list_type = "following" %} + {% set followed_urls = set() %} + {% include "federation/_actor_list_items.html" %} +
+{% endblock %}