Add following/followers lists and per-actor timeline pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s

- 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 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-22 13:42:16 +00:00
parent b694d1f4f9
commit 27e86c580b
7 changed files with 285 additions and 6 deletions

View File

@@ -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/<int:id>")
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/<int:id>/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")

View File

@@ -0,0 +1,60 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" %}
<form method="post" action="{{ url_for('social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% elif list_type == "followers" %}
{% if a.actor_url in followed_urls %}
<form method="post" action="{{ url_for('social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('social.' ~ list_type ~ '_list_page', page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -4,8 +4,15 @@
{% if items %}
{% set last = items[-1] %}
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% if timeline_type == "actor" %}
<div hx-get="{{ url_for('social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% else %}
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,53 @@
{% extends "federation/base.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "federation/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -16,6 +16,8 @@
{% if actor %}
<a href="{{ url_for('social.home_timeline') }}" class="hover:underline">Timeline</a>
<a href="{{ url_for('social.public_timeline') }}" class="hover:underline">Public</a>
<a href="{{ url_for('social.following_list') }}" class="hover:underline">Following</a>
<a href="{{ url_for('social.followers_list') }}" class="hover:underline">Followers</a>
<a href="{{ url_for('social.search') }}" class="hover:underline">Search</a>
<a href="{{ url_for('social.notifications') }}" class="hover:underline relative">
Notifications

View File

@@ -0,0 +1,12 @@
{% extends "federation/base.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "federation/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "federation/base.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = set() %}
{% include "federation/_actor_list_items.html" %}
</div>
{% endblock %}