Add per-app AP social UI for blog, market, and events
Lightweight social pages (search, follow/unfollow, followers, following, actor timeline) auto-registered for AP-enabled apps via shared blueprint. Federation keeps the full social hub. Followers scoped per app_domain; post cards show "View on Hub" link instead of interaction buttons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) %}
|
||||
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
|
||||
{% if actor %}
|
||||
<nav class="flex gap-3 text-sm items-center flex-wrap">
|
||||
<a href="{{ url_for('ap_social.search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
|
||||
Search
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.following_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
|
||||
Following
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.followers_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
|
||||
Followers
|
||||
</a>
|
||||
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200">
|
||||
@{{ actor.preferred_username }}
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
|
||||
Hub
|
||||
</a>
|
||||
</nav>
|
||||
{% else %}
|
||||
<nav class="flex gap-3 text-sm items-center">
|
||||
<a href="{{ url_for('ap_social.search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
|
||||
Search
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
|
||||
Hub
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
10
shared/browser/templates/_types/social_lite/index.html
Normal file
10
shared/browser/templates/_types/social_lite/index.html
Normal file
@@ -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 %}
|
||||
63
shared/browser/templates/social/_actor_list_items.html
Normal file
63
shared/browser/templates/social/_actor_list_items.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% 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"
|
||||
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
|
||||
{% 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">
|
||||
{% if list_type == "following" and a.id %}
|
||||
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<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" or a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
<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('ap_social.follow') }}"
|
||||
hx-post="{{ url_for('ap_social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div hx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
53
shared/browser/templates/social/_post_card.html
Normal file
53
shared/browser/templates/social/_post_card.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
|
||||
{% if item.boosted_by %}
|
||||
<div class="text-sm text-stone-500 mb-2">
|
||||
Boosted by {{ item.boosted_by }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
{% if item.actor_icon %}
|
||||
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
|
||||
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
|
||||
<span class="text-sm text-stone-500">
|
||||
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
|
||||
</span>
|
||||
<span class="text-sm text-stone-400 ml-auto">
|
||||
{% if item.published %}
|
||||
{{ item.published.strftime('%b %d, %H:%M') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if item.summary %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 flex gap-3 text-sm text-stone-400">
|
||||
{% if item.url and item.post_type == "remote" %}
|
||||
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
|
||||
original
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.object_id %}
|
||||
<a href="{{ federation_url('/social/') }}" class="hover:underline">
|
||||
View on Hub
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
61
shared/browser/templates/social/_search_results.html
Normal file
61
shared/browser/templates/social/_search_results.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% 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"
|
||||
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
|
||||
{% 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">
|
||||
{% if a.id %}
|
||||
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
|
||||
{% endif %}
|
||||
<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 a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
<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('ap_social.follow') }}"
|
||||
hx-post="{{ url_for('ap_social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div hx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
13
shared/browser/templates/social/_timeline_items.html
Normal file
13
shared/browser/templates/social/_timeline_items.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% for item in items %}
|
||||
{% include "social/_post_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div hx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
53
shared/browser/templates/social/actor_timeline.html
Normal file
53
shared/browser/templates/social/actor_timeline.html
Normal file
@@ -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 %}
|
||||
<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('ap_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('ap_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 "social/_timeline_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
shared/browser/templates/social/followers.html
Normal file
12
shared/browser/templates/social/followers.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Followers — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_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 "social/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
shared/browser/templates/social/following.html
Normal file
13
shared/browser/templates/social/following.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Following — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_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 = [] %}
|
||||
{% include "social/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
shared/browser/templates/social/search.html
Normal file
32
shared/browser/templates/social/search.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Search — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
|
||||
hx-get="{{ url_for('ap_social.search_page') }}"
|
||||
hx-target="#search-results"
|
||||
hx-push-url="{{ url_for('ap_social.search') }}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
placeholder="Search users or @user@instance.tld">
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if query and total %}
|
||||
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
|
||||
{% elif query %}
|
||||
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div id="search-results">
|
||||
{% include "social/_search_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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(
|
||||
|
||||
271
shared/infrastructure/ap_social.py
Normal file
271
shared/infrastructure/ap_social.py
Normal file
@@ -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/<int:id>")
|
||||
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/<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(
|
||||
"social/_timeline_items.html",
|
||||
items=items,
|
||||
timeline_type="actor",
|
||||
actor_id=id,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
return bp
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user