Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all social blueprint routes and federation profile to SX content builders, delete 12 unused Jinja templates and social_lite layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
|
||||
{% block social_content %}
|
||||
<div class="py-8">
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
|
||||
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
|
||||
{% if actor.summary %}
|
||||
<p class="mt-2">{{ actor.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
|
||||
{% if activities %}
|
||||
<div class="space-y-4">
|
||||
{% for a in activities %}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium">{{ a.activity_type }}</span>
|
||||
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
|
||||
</div>
|
||||
{% if a.object_type %}
|
||||
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">No activities yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,42 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,63 +0,0 @@
|
||||
{% 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') }}"
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-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') }}"
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-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 sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,53 +0,0 @@
|
||||
<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>
|
||||
@@ -1,61 +0,0 @@
|
||||
{% 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') }}"
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-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') }}"
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-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 sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% for item in items %}
|
||||
{% include "social/_post_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Social — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Social</h1>
|
||||
|
||||
{% if actor %}
|
||||
<div class="space-y-3">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Search</div>
|
||||
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Following</div>
|
||||
<div class="text-sm text-stone-500">Accounts you follow</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Followers</div>
|
||||
<div class="text-sm text-stone-500">Accounts following you here</div>
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Hub</div>
|
||||
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
|
||||
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{% 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"
|
||||
sx-get="{{ url_for('ap_social.search_page') }}"
|
||||
sx-target="#search-results"
|
||||
sx-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 %}
|
||||
@@ -57,6 +57,77 @@ def _is_aggregate(app_name: str) -> bool:
|
||||
return app_name == "federation"
|
||||
|
||||
|
||||
async def _render_profile_sx(actor, activities, total):
|
||||
"""Render the federation actor profile page using SX."""
|
||||
from markupsafe import escape
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.config import config
|
||||
|
||||
def _e(v):
|
||||
s = str(v) if v else ""
|
||||
return str(escape(s)).replace('"', '\\"')
|
||||
|
||||
username = _e(actor.preferred_username)
|
||||
display_name = _e(actor.display_name or actor.preferred_username)
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
|
||||
summary_el = ""
|
||||
if actor.summary:
|
||||
summary_el = f'(p :class "mt-2" "{_e(actor.summary)}")'
|
||||
|
||||
activity_items = []
|
||||
for a in activities:
|
||||
ts = ""
|
||||
if a.published:
|
||||
ts = a.published.strftime("%Y-%m-%d %H:%M")
|
||||
obj_el = ""
|
||||
if a.object_type:
|
||||
obj_el = f'(span :class "text-sm text-stone-500" "{_e(a.object_type)}")'
|
||||
activity_items.append(
|
||||
f'(div :class "bg-white rounded-lg shadow p-4"'
|
||||
f' (div :class "flex justify-between items-start"'
|
||||
f' (span :class "font-medium" "{_e(a.activity_type)}")'
|
||||
f' (span :class "text-sm text-stone-400" "{_e(ts)}"))'
|
||||
f' {obj_el})')
|
||||
|
||||
if activities:
|
||||
activities_el = ('(div :class "space-y-4" ' +
|
||||
" ".join(activity_items) + ")")
|
||||
else:
|
||||
activities_el = '(p :class "text-stone-500" "No activities yet.")'
|
||||
|
||||
content = (
|
||||
f'(div :id "main-panel"'
|
||||
f' (div :class "py-8"'
|
||||
f' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
|
||||
f' (h1 :class "text-2xl font-bold" "{display_name}")'
|
||||
f' (p :class "text-stone-500" "@{username}@{_e(ap_domain)}")'
|
||||
f' {summary_el})'
|
||||
f' (h2 :class "text-xl font-bold mb-4" "Activities ({total})")'
|
||||
f' {activities_el}))')
|
||||
|
||||
tctx = await get_template_context()
|
||||
|
||||
if is_htmx_request():
|
||||
# Import federation layout for OOB headers
|
||||
try:
|
||||
from federation.sxc.pages import _social_oob
|
||||
oob_headers = _social_oob(tctx)
|
||||
except ImportError:
|
||||
oob_headers = ""
|
||||
return sx_response(oob_page_sx(oobs=oob_headers, content=content))
|
||||
else:
|
||||
try:
|
||||
from federation.sxc.pages import _social_full
|
||||
header_rows = _social_full(tctx)
|
||||
except ImportError:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
header_rows = root_header_sx(tctx)
|
||||
return full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
def create_activitypub_blueprint(app_name: str) -> Blueprint:
|
||||
"""Return a Blueprint with AP endpoints for *app_name*."""
|
||||
bp = Blueprint("activitypub", __name__)
|
||||
@@ -272,16 +343,10 @@ def create_activitypub_blueprint(app_name: str) -> Blueprint:
|
||||
|
||||
# HTML: federation renders its own profile; other apps redirect there
|
||||
if aggregate:
|
||||
from quart import render_template
|
||||
activities, total = await services.federation.get_outbox(
|
||||
g._ap_s, username, page=1, per_page=20,
|
||||
)
|
||||
return await render_template(
|
||||
"federation/profile.html",
|
||||
actor=actor,
|
||||
activities=activities,
|
||||
total=total,
|
||||
)
|
||||
return await _render_profile_sx(actor, activities, total)
|
||||
from quart import redirect
|
||||
return redirect(f"https://{fed_domain}/users/{username}")
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
Lightweight social UI for blog/market/events. Federation keeps the full
|
||||
social hub (timeline, compose, notifications, interactions).
|
||||
|
||||
All rendering uses s-expressions (no Jinja templates).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, Response
|
||||
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -77,15 +79,36 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
async def _render_social_page(content: str, actor=None, title: str = "Social"):
|
||||
"""Render a full social page or OOB response depending on request type."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from shared.infrastructure.ap_social_sx import (
|
||||
_social_full_headers, _social_oob_headers,
|
||||
)
|
||||
|
||||
tctx = await get_template_context()
|
||||
kw = {"actor": actor}
|
||||
|
||||
if is_htmx_request():
|
||||
oob_headers = _social_oob_headers(tctx, **kw)
|
||||
return sx_response(oob_page_sx(
|
||||
oobs=oob_headers,
|
||||
content=content,
|
||||
))
|
||||
else:
|
||||
header_rows = _social_full_headers(tctx, **kw)
|
||||
return full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
# -- Index ----------------------------------------------------------------
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
return await render_template(
|
||||
"social/index.html",
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_index_content_sx
|
||||
content = social_index_content_sx(actor)
|
||||
return await _render_social_page(content, actor, title="Social")
|
||||
|
||||
# -- Search ---------------------------------------------------------------
|
||||
|
||||
@@ -103,15 +126,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_search_content_sx
|
||||
content = social_search_content_sx(query, actors, total, 1, followed_urls, actor)
|
||||
return await _render_social_page(content, actor, title="Search")
|
||||
|
||||
@bp.get("/search/page")
|
||||
async def search_page():
|
||||
@@ -130,15 +147,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import search_results_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = search_results_sx(actors, total, page, query, followed_urls, actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Follow / Unfollow ----------------------------------------------------
|
||||
|
||||
@@ -169,7 +181,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
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."""
|
||||
"""Re-render a single actor card after follow/unfollow."""
|
||||
remote_dto = await services.federation.get_or_fetch_remote_actor(
|
||||
g._ap_s, remote_actor_url,
|
||||
)
|
||||
@@ -181,15 +193,12 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
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,
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(
|
||||
[remote_dto], 0, 1, list_type, followed_urls, actor,
|
||||
)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Followers ------------------------------------------------------------
|
||||
|
||||
@@ -203,14 +212,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_followers_content_sx
|
||||
content = social_followers_content_sx(actors, total, 1, followed_urls, actor)
|
||||
return await _render_social_page(content, actor, title="Followers")
|
||||
|
||||
@bp.get("/followers/page")
|
||||
async def followers_list_page():
|
||||
@@ -223,15 +227,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Following ------------------------------------------------------------
|
||||
|
||||
@@ -241,13 +240,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
actors, total = await services.federation.get_following(
|
||||
g._ap_s, actor.preferred_username,
|
||||
)
|
||||
return await render_template(
|
||||
"social/following.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=1,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_following_content_sx
|
||||
content = social_following_content_sx(actors, total, 1, actor)
|
||||
return await _render_social_page(content, actor, title="Following")
|
||||
|
||||
@bp.get("/following/page")
|
||||
async def following_list_page():
|
||||
@@ -256,15 +251,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
actors, total = await services.federation.get_following(
|
||||
g._ap_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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(actors, total, page, "following", set(), actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Actor timeline -------------------------------------------------------
|
||||
|
||||
@@ -295,13 +285,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
)
|
||||
).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,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx
|
||||
content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor)
|
||||
return await _render_social_page(content, actor)
|
||||
|
||||
@bp.get("/actor/<int:id>/timeline")
|
||||
async def actor_timeline_page(id: int):
|
||||
@@ -316,12 +302,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
items = await services.federation.get_actor_timeline(
|
||||
g._ap_s, id, before=before,
|
||||
)
|
||||
return await render_template(
|
||||
"social/_timeline_items.html",
|
||||
items=items,
|
||||
timeline_type="actor",
|
||||
actor_id=id,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import timeline_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = timeline_items_sx(items, "actor", id, actor)
|
||||
return sx_response(content)
|
||||
|
||||
return bp
|
||||
|
||||
593
shared/infrastructure/ap_social_sx.py
Normal file
593
shared/infrastructure/ap_social_sx.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""SX content builders for the per-app AP social blueprint.
|
||||
|
||||
Builds s-expression source strings for all social pages, replacing
|
||||
the Jinja templates in shared/browser/templates/social/.
|
||||
|
||||
All dynamic values (URLs, CSRF tokens) are resolved server-side in Python
|
||||
and embedded as string literals — the SX is rendered client-side where
|
||||
server primitives like url-for and csrf-token are unavailable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sx.helpers import (
|
||||
sx_call, root_header_sx, oob_header_sx,
|
||||
mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout — "social-lite": root header + social nav row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_social_layout() -> None:
|
||||
"""Register the social-lite layout. Called once during app startup."""
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout(
|
||||
"social-lite",
|
||||
_social_full_headers,
|
||||
_social_oob_headers,
|
||||
_social_mobile,
|
||||
)
|
||||
|
||||
|
||||
def _social_nav_items(actor: Any) -> str:
|
||||
"""Build the social nav items as sx source.
|
||||
|
||||
All URLs resolved server-side via Quart's url_for.
|
||||
"""
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
|
||||
parts: list[str] = []
|
||||
if actor:
|
||||
following_url = _e(url_for("ap_social.following_list"))
|
||||
followers_url = _e(url_for("ap_social.followers_list"))
|
||||
username = _e(getattr(actor, "preferred_username", ""))
|
||||
try:
|
||||
profile_url = _e(url_for("activitypub.actor_profile",
|
||||
username=actor.preferred_username))
|
||||
except Exception:
|
||||
profile_url = ""
|
||||
|
||||
parts.append(f'(a :href "{search_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
|
||||
parts.append(f'(a :href "{following_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Following")')
|
||||
parts.append(f'(a :href "{followers_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Followers")')
|
||||
if profile_url:
|
||||
parts.append(f'(a :href "{profile_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm"'
|
||||
f' "@{username}")')
|
||||
parts.append(f'(a :href "{hub_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
|
||||
f' "Hub")')
|
||||
else:
|
||||
parts.append(f'(a :href "{search_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
|
||||
parts.append(f'(a :href "{hub_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
|
||||
f' "Hub")')
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _social_header_row(actor: Any) -> str:
|
||||
"""Build the social nav header row as sx source."""
|
||||
nav = _social_nav_items(actor)
|
||||
return (
|
||||
f'(div :id "social-lite-header-child"'
|
||||
f' :class "flex flex-col items-center md:flex-row justify-center'
|
||||
f' md:justify-between w-full p-1 bg-stone-300"'
|
||||
f' (div :class "w-full flex flex-row items-center gap-2 flex-wrap"'
|
||||
f' (nav :class "flex gap-3 text-sm items-center flex-wrap" {nav})))'
|
||||
)
|
||||
|
||||
|
||||
def _social_full_headers(ctx: dict, **kw: Any) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
actor = kw.get("actor")
|
||||
social_row = _social_header_row(actor)
|
||||
return "(<> " + root_hdr + " " + social_row + ")"
|
||||
|
||||
|
||||
def _social_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
actor = kw.get("actor")
|
||||
social_row = _social_header_row(actor)
|
||||
rows = "(<> " + root_hdr + " " + social_row + ")"
|
||||
return oob_header_sx("root-header-child", "social-lite-header-child", rows)
|
||||
|
||||
|
||||
def _social_mobile(ctx: dict, **kw: Any) -> str:
|
||||
return mobile_menu_sx(mobile_root_nav_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _e(val: Any) -> str:
|
||||
"""Escape a value for safe embedding in sx source strings."""
|
||||
s = str(val) if val else ""
|
||||
return str(escape(s)).replace('"', '\\"')
|
||||
|
||||
|
||||
def _esc_raw(html: str) -> str:
|
||||
"""Escape raw HTML for embedding as a string literal in sx.
|
||||
|
||||
The string will be passed to (raw! ...) so it should NOT be HTML-escaped,
|
||||
only the sx string delimiters need escaping.
|
||||
"""
|
||||
return html.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _actor_initial(a: Any) -> str:
|
||||
"""Get the uppercase first character of an actor's display name or username."""
|
||||
name = _actor_name(a)
|
||||
return name[0].upper() if name else "?"
|
||||
|
||||
|
||||
def _actor_name(a: Any) -> str:
|
||||
"""Get display name or preferred username from an actor (DTO or dict)."""
|
||||
if isinstance(a, dict):
|
||||
return a.get("display_name") or a.get("preferred_username") or ""
|
||||
return getattr(a, "display_name", None) or getattr(a, "preferred_username", "") or ""
|
||||
|
||||
|
||||
def _attr(a: Any, key: str, default: str = "") -> Any:
|
||||
"""Get attribute from DTO or dict."""
|
||||
if isinstance(a, dict):
|
||||
return a.get(key, default)
|
||||
return getattr(a, key, default)
|
||||
|
||||
|
||||
def _strip_tags(s: str) -> str:
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
|
||||
def _csrf() -> str:
|
||||
"""Get the CSRF token as a string."""
|
||||
from quart import current_app
|
||||
fn = current_app.jinja_env.globals.get("csrf_token")
|
||||
if callable(fn):
|
||||
return str(fn())
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actor card — used in search results, followers, following
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _actor_card_sx(a: Any, followed_urls: set, actor: Any,
|
||||
list_type: str = "search") -> str:
|
||||
"""Build sx source for a single actor card."""
|
||||
from quart import url_for
|
||||
|
||||
actor_url = _attr(a, "actor_url", "")
|
||||
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||
icon_url = _attr(a, "icon_url", "")
|
||||
display_name = _actor_name(a)
|
||||
username = _attr(a, "preferred_username", "")
|
||||
domain = _attr(a, "domain", "")
|
||||
summary = _attr(a, "summary", "")
|
||||
actor_id = _attr(a, "id")
|
||||
csrf = _e(_csrf())
|
||||
|
||||
# Avatar
|
||||
if icon_url:
|
||||
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-12 h-12 rounded-full")'
|
||||
else:
|
||||
initial = _actor_initial(a)
|
||||
avatar = (f'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold" "{initial}")')
|
||||
|
||||
# Name link
|
||||
if (list_type in ("following", "search")) and actor_id:
|
||||
tl_url = _e(url_for("ap_social.actor_timeline", id=actor_id))
|
||||
name_el = (f'(a :href "{tl_url}"'
|
||||
f' :class "font-semibold text-stone-900 hover:underline"'
|
||||
f' "{_e(display_name)}")')
|
||||
else:
|
||||
name_el = (f'(a :href "https://{_e(domain)}/@{_e(username)}"'
|
||||
f' :target "_blank" :rel "noopener"'
|
||||
f' :class "font-semibold text-stone-900 hover:underline"'
|
||||
f' "{_e(display_name)}")')
|
||||
|
||||
handle = f'(div :class "text-sm text-stone-500" "@{_e(username)}@{_e(domain)}")'
|
||||
|
||||
# Summary
|
||||
summary_el = ""
|
||||
if summary:
|
||||
clean = _strip_tags(summary)
|
||||
summary_el = (f'(div :class "text-sm text-stone-600 mt-1 truncate"'
|
||||
f' "{_e(clean)}")')
|
||||
|
||||
# Follow/unfollow button
|
||||
button_el = ""
|
||||
if actor:
|
||||
is_followed = (list_type == "following" or actor_url in (followed_urls or set()))
|
||||
if is_followed:
|
||||
unfollow_url = _e(url_for("ap_social.unfollow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{unfollow_url}"'
|
||||
f' :sx-post "{unfollow_url}"'
|
||||
f' :sx-target "closest article" :sx-swap "outerHTML"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100"'
|
||||
f' "Unfollow")))')
|
||||
else:
|
||||
follow_url = _e(url_for("ap_social.follow"))
|
||||
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{follow_url}"'
|
||||
f' :sx-post "{follow_url}"'
|
||||
f' :sx-target "closest article" :sx-swap "outerHTML"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700"'
|
||||
f' "{label}")))')
|
||||
|
||||
return (
|
||||
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200'
|
||||
f' p-4 mb-3 flex items-center gap-4" :id "actor-{_e(safe_id)}"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1 min-w-0" {name_el} {handle} {summary_el})'
|
||||
f' {button_el})'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actor list items — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def actor_list_items_sx(actors: list, total: int, page: int,
|
||||
list_type: str, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for a list of actor cards with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, followed_urls, actor, list_type) for a in actors]
|
||||
|
||||
# Infinite scroll sentinel
|
||||
if len(actors) >= 20:
|
||||
next_page = page + 1
|
||||
ep = f"ap_social.{list_type}_list_page"
|
||||
next_url = _e(url_for(ep, page=next_page))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search results — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def search_results_sx(actors: list, total: int, page: int,
|
||||
query: str, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for search results with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, followed_urls, actor, "search") for a in actors]
|
||||
|
||||
if len(actors) >= 20:
|
||||
next_page = page + 1
|
||||
next_url = _e(url_for("ap_social.search_page", q=query, page=next_page))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post card — timeline item
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post_card_sx(item: Any) -> str:
|
||||
"""Build sx source for a single post/status card."""
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
actor_name = _attr(item, "actor_name", "")
|
||||
actor_username = _attr(item, "actor_username", "")
|
||||
actor_domain = _attr(item, "actor_domain", "")
|
||||
actor_icon = _attr(item, "actor_icon", "")
|
||||
content = _attr(item, "content", "")
|
||||
summary = _attr(item, "summary", "")
|
||||
published = _attr(item, "published")
|
||||
boosted_by = _attr(item, "boosted_by", "")
|
||||
url = _attr(item, "url", "")
|
||||
object_id = _attr(item, "object_id", "")
|
||||
post_type = _attr(item, "post_type", "")
|
||||
|
||||
boost_el = ""
|
||||
if boosted_by:
|
||||
boost_el = (f'(div :class "text-sm text-stone-500 mb-2"'
|
||||
f' "Boosted by {_e(boosted_by)}")')
|
||||
|
||||
# Avatar
|
||||
if actor_icon:
|
||||
avatar = f'(img :src "{_e(actor_icon)}" :alt "" :class "w-10 h-10 rounded-full")'
|
||||
else:
|
||||
initial = actor_name[0].upper() if actor_name else "?"
|
||||
avatar = (f'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold text-sm" "{initial}")')
|
||||
|
||||
# Handle
|
||||
handle_text = f"@{_e(actor_username)}"
|
||||
if actor_domain:
|
||||
handle_text += f"@{_e(actor_domain)}"
|
||||
|
||||
# Timestamp
|
||||
time_el = ""
|
||||
if published:
|
||||
if hasattr(published, "strftime"):
|
||||
ts = published.strftime("%b %d, %H:%M")
|
||||
else:
|
||||
ts = str(published)
|
||||
time_el = f'(span :class "text-sm text-stone-400 ml-auto" "{_e(ts)}")'
|
||||
|
||||
# Content — raw HTML from AP, render with raw!
|
||||
if summary:
|
||||
content_el = (
|
||||
f'(details :class "mt-2"'
|
||||
f' (summary :class "text-stone-500 cursor-pointer" "CW: {_e(summary)}")'
|
||||
f' (div :class "mt-2 prose prose-sm prose-stone max-w-none"'
|
||||
f' (raw! "{_esc_raw(content)}")))')
|
||||
else:
|
||||
content_el = (
|
||||
f'(div :class "mt-2 prose prose-sm prose-stone max-w-none"'
|
||||
f' (raw! "{_esc_raw(content)}"))')
|
||||
|
||||
# Links
|
||||
links: list[str] = []
|
||||
if url and post_type == "remote":
|
||||
links.append(
|
||||
f'(a :href "{_e(url)}" :target "_blank" :rel "noopener"'
|
||||
f' :class "hover:underline" "original")')
|
||||
if object_id:
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
links.append(
|
||||
f'(a :href "{hub_url}"'
|
||||
f' :class "hover:underline" "View on Hub")')
|
||||
links_el = ""
|
||||
if links:
|
||||
links_el = ('(div :class "mt-2 flex gap-3 text-sm text-stone-400" '
|
||||
+ " ".join(links) + ")")
|
||||
|
||||
return (
|
||||
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
|
||||
f' {boost_el}'
|
||||
f' (div :class "flex items-start gap-3"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1 min-w-0"'
|
||||
f' (div :class "flex items-baseline gap-2"'
|
||||
f' (span :class "font-semibold text-stone-900" "{_e(actor_name)}")'
|
||||
f' (span :class "text-sm text-stone-500" "{handle_text}")'
|
||||
f' {time_el})'
|
||||
f' {content_el}'
|
||||
f' {links_el})))'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeline items — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def timeline_items_sx(items: list, timeline_type: str = "",
|
||||
actor_id: int | None = None, actor: Any = None) -> str:
|
||||
"""Build sx source for timeline items with infinite scroll sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_post_card_sx(item) for item in items]
|
||||
|
||||
if items and timeline_type == "actor" and actor_id:
|
||||
last = items[-1]
|
||||
published = _attr(last, "published")
|
||||
if published and hasattr(published, "isoformat"):
|
||||
before = published.isoformat()
|
||||
else:
|
||||
before = str(published) if published else ""
|
||||
if before:
|
||||
next_url = _e(url_for("ap_social.actor_timeline_page",
|
||||
id=actor_id, before=before))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full page content builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def social_index_content_sx(actor: Any) -> str:
|
||||
"""Build sx source for the social index page content."""
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
|
||||
if actor:
|
||||
following_url = _e(url_for("ap_social.following_list"))
|
||||
followers_url = _e(url_for("ap_social.followers_list"))
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
|
||||
f' (div :class "space-y-3"'
|
||||
f' (a :href "{search_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Search")'
|
||||
f' (div :class "text-sm text-stone-500" "Find and follow accounts on the fediverse"))'
|
||||
f' (a :href "{following_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Following")'
|
||||
f' (div :class "text-sm text-stone-500" "Accounts you follow"))'
|
||||
f' (a :href "{followers_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Followers")'
|
||||
f' (div :class "text-sm text-stone-500" "Accounts following you here"))'
|
||||
f' (a :href "{hub_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Hub")'
|
||||
f' (div :class "text-sm text-stone-500"'
|
||||
f' "Full social experience \\u2014 timeline, compose, notifications"))))')
|
||||
else:
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
|
||||
f' (p :class "text-stone-500"'
|
||||
f' (a :href "{search_url}" :class "underline" "Search")'
|
||||
f' " for accounts on the fediverse, or visit the "'
|
||||
f' (a :href "{hub_url}" :class "underline" "Hub")'
|
||||
f' " to get started."))')
|
||||
|
||||
|
||||
def social_search_content_sx(query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for the search page content."""
|
||||
from quart import url_for
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
search_page_url = _e(url_for("ap_social.search_page"))
|
||||
|
||||
# Results message
|
||||
msg = ""
|
||||
if query and total:
|
||||
s = "s" if total != 1 else ""
|
||||
msg = (f'(p :class "text-sm text-stone-500 mb-4"'
|
||||
f' "{total} result{s} for " (strong "{_e(query)}"))')
|
||||
elif query:
|
||||
msg = (f'(p :class "text-stone-500 mb-4"'
|
||||
f' "No results found for " (strong "{_e(query)}"))')
|
||||
|
||||
results = search_results_sx(actors, total, page, query, followed_urls, actor)
|
||||
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Search")'
|
||||
f' (form :method "get" :action "{search_url}"'
|
||||
f' :sx-get "{search_page_url}"'
|
||||
f' :sx-target "#search-results"'
|
||||
f' :sx-push-url "{search_url}"'
|
||||
f' :class "mb-6"'
|
||||
f' (div :class "flex gap-2"'
|
||||
f' (input :type "text" :name "q" :value "{_e(query)}"'
|
||||
f' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2'
|
||||
f' focus:outline-none focus:ring-2 focus:ring-stone-500"'
|
||||
f' :placeholder "Search users or @user@instance.tld")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700"'
|
||||
f' "Search")))'
|
||||
f' {msg}'
|
||||
f' (div :id "search-results" {results}))'
|
||||
)
|
||||
|
||||
|
||||
def social_followers_content_sx(actors: list, total: int, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for the followers page content."""
|
||||
items = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Followers "'
|
||||
f' (span :class "text-stone-400 font-normal" "({total})"))'
|
||||
f' (div :id "actor-list" {items}))'
|
||||
)
|
||||
|
||||
|
||||
def social_following_content_sx(actors: list, total: int,
|
||||
page: int, actor: Any) -> str:
|
||||
"""Build sx source for the following page content."""
|
||||
items = actor_list_items_sx(actors, total, page, "following", set(), actor)
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Following "'
|
||||
f' (span :class "text-stone-400 font-normal" "({total})"))'
|
||||
f' (div :id "actor-list" {items}))'
|
||||
)
|
||||
|
||||
|
||||
def social_actor_timeline_content_sx(remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Build sx source for the actor timeline page content."""
|
||||
from quart import url_for
|
||||
|
||||
ra = remote_actor
|
||||
display_name = _actor_name(ra)
|
||||
username = _attr(ra, "preferred_username", "")
|
||||
domain = _attr(ra, "domain", "")
|
||||
icon_url = _attr(ra, "icon_url", "")
|
||||
summary = _attr(ra, "summary", "")
|
||||
actor_url = _attr(ra, "actor_url", "")
|
||||
ra_id = _attr(ra, "id")
|
||||
csrf = _e(_csrf())
|
||||
|
||||
# Avatar
|
||||
if icon_url:
|
||||
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-16 h-16 rounded-full")'
|
||||
else:
|
||||
initial = display_name[0].upper() if display_name else "?"
|
||||
avatar = (f'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold text-xl" "{initial}")')
|
||||
|
||||
# Summary — raw HTML from AP
|
||||
summary_el = ""
|
||||
if summary:
|
||||
summary_el = (f'(div :class "text-sm text-stone-600 mt-2"'
|
||||
f' (raw! "{_esc_raw(summary)}"))')
|
||||
|
||||
# Follow/unfollow button
|
||||
button_el = ""
|
||||
if actor:
|
||||
if is_following:
|
||||
unfollow_url = _e(url_for("ap_social.unfollow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{unfollow_url}"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100"'
|
||||
f' "Unfollow")))')
|
||||
else:
|
||||
follow_url = _e(url_for("ap_social.follow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{follow_url}"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"'
|
||||
f' "Follow")))')
|
||||
|
||||
tl = timeline_items_sx(items, "actor", ra_id, actor)
|
||||
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
|
||||
f' (div :class "flex items-center gap-4"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1"'
|
||||
f' (h1 :class "text-xl font-bold" "{_e(display_name)}")'
|
||||
f' (div :class "text-stone-500" "@{_e(username)}@{_e(domain)}")'
|
||||
f' {summary_el})'
|
||||
f' {button_el}))'
|
||||
f' (div :id "timeline" {tl}))'
|
||||
)
|
||||
@@ -160,6 +160,8 @@ def create_base_app(
|
||||
# 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
|
||||
from shared.infrastructure.ap_social_sx import setup_social_layout
|
||||
setup_social_layout()
|
||||
app.register_blueprint(create_ap_social_blueprint(name))
|
||||
|
||||
# --- device id (all apps, including account) ---
|
||||
|
||||
@@ -257,6 +257,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]:
|
||||
def prim_join(sep: str, coll: list) -> str:
|
||||
return sep.join(str(x) for x in coll)
|
||||
|
||||
@register_primitive("replace")
|
||||
def prim_replace(s: str, old: str, new: str) -> str:
|
||||
return s.replace(old, new)
|
||||
|
||||
@register_primitive("strip-tags")
|
||||
def prim_strip_tags(s: str) -> str:
|
||||
"""Strip HTML tags from a string."""
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
@register_primitive("slice")
|
||||
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
|
||||
"""Slice a string or list: (slice coll start end?)."""
|
||||
start = int(start)
|
||||
if end is None or end is NIL:
|
||||
return coll[start:]
|
||||
return coll[start:int(end)]
|
||||
|
||||
@register_primitive("starts-with?")
|
||||
def prim_starts_with(s, prefix: str) -> bool:
|
||||
if not isinstance(s, str):
|
||||
|
||||
@@ -41,6 +41,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"nav-tree",
|
||||
"get-children",
|
||||
"g",
|
||||
"csrf-token",
|
||||
})
|
||||
|
||||
|
||||
@@ -314,6 +315,17 @@ async def _io_g(
|
||||
return getattr(g, key, None)
|
||||
|
||||
|
||||
async def _io_csrf_token(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(csrf-token)`` → current CSRF token string."""
|
||||
from quart import current_app
|
||||
csrf = current_app.jinja_env.globals.get("csrf_token")
|
||||
if callable(csrf):
|
||||
return csrf()
|
||||
return ""
|
||||
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
@@ -326,4 +338,5 @@ _IO_HANDLERS: dict[str, Any] = {
|
||||
"nav-tree": _io_nav_tree,
|
||||
"get-children": _io_get_children,
|
||||
"g": _io_g,
|
||||
"csrf-token": _io_csrf_token,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user