diff --git a/federation/templates/federation/profile.html b/federation/templates/federation/profile.html deleted file mode 100644 index 2e21a08..0000000 --- a/federation/templates/federation/profile.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "_types/social/index.html" %} -{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %} -{% block social_content %} -
-
-

{{ actor.display_name or actor.preferred_username }}

-

@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}

- {% if actor.summary %} -

{{ actor.summary }}

- {% endif %} -
- -

Activities ({{ total }})

- {% if activities %} -
- {% for a in activities %} -
-
- {{ a.activity_type }} - {{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }} -
- {% if a.object_type %} - {{ a.object_type }} - {% endif %} -
- {% endfor %} -
- {% else %} -

No activities yet.

- {% endif %} -
-{% endblock %} diff --git a/shared/browser/templates/_types/social_lite/header/_header.html b/shared/browser/templates/_types/social_lite/header/_header.html deleted file mode 100644 index 6e7b337..0000000 --- a/shared/browser/templates/_types/social_lite/header/_header.html +++ /dev/null @@ -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) %} -
- {% if actor %} - - {% else %} - - {% endif %} -
- {% endcall %} -{% endmacro %} diff --git a/shared/browser/templates/_types/social_lite/index.html b/shared/browser/templates/_types/social_lite/index.html deleted file mode 100644 index dc3e25d..0000000 --- a/shared/browser/templates/_types/social_lite/index.html +++ /dev/null @@ -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 %} diff --git a/shared/browser/templates/social/_actor_list_items.html b/shared/browser/templates/social/_actor_list_items.html deleted file mode 100644 index ad47be4..0000000 --- a/shared/browser/templates/social/_actor_list_items.html +++ /dev/null @@ -1,63 +0,0 @@ -{% for a in actors %} -
- {% if a.icon_url %} - - {% else %} -
- {{ (a.display_name or a.preferred_username)[0] | upper }} -
- {% endif %} - -
- {% if list_type == "following" and a.id %} - - {{ a.display_name or a.preferred_username }} - - {% else %} - - {{ a.display_name or a.preferred_username }} - - {% endif %} -
@{{ a.preferred_username }}@{{ a.domain }}
- {% if a.summary %} -
{{ a.summary | striptags }}
- {% endif %} -
- - {% if actor %} -
- {% if list_type == "following" or a.actor_url in (followed_urls or []) %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-{% endfor %} - -{% if actors | length >= 20 %} -
-
-{% endif %} diff --git a/shared/browser/templates/social/_post_card.html b/shared/browser/templates/social/_post_card.html deleted file mode 100644 index 5556652..0000000 --- a/shared/browser/templates/social/_post_card.html +++ /dev/null @@ -1,53 +0,0 @@ -
- {% if item.boosted_by %} -
- Boosted by {{ item.boosted_by }} -
- {% endif %} - -
- {% if item.actor_icon %} - - {% else %} -
- {{ item.actor_name[0] | upper if item.actor_name else '?' }} -
- {% endif %} - -
-
- {{ item.actor_name }} - - @{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %} - - - {% if item.published %} - {{ item.published.strftime('%b %d, %H:%M') }} - {% endif %} - -
- - {% if item.summary %} -
- CW: {{ item.summary }} -
{{ item.content | safe }}
-
- {% else %} -
{{ item.content | safe }}
- {% endif %} - -
- {% if item.url and item.post_type == "remote" %} - - original - - {% endif %} - {% if item.object_id %} - - View on Hub - - {% endif %} -
-
-
-
diff --git a/shared/browser/templates/social/_search_results.html b/shared/browser/templates/social/_search_results.html deleted file mode 100644 index ad3c88a..0000000 --- a/shared/browser/templates/social/_search_results.html +++ /dev/null @@ -1,61 +0,0 @@ -{% for a in actors %} -
- {% if a.icon_url %} - - {% else %} -
- {{ (a.display_name or a.preferred_username)[0] | upper }} -
- {% endif %} - -
- {% if a.id %} - - {{ a.display_name or a.preferred_username }} - - {% else %} - {{ a.display_name or a.preferred_username }} - {% endif %} -
@{{ a.preferred_username }}@{{ a.domain }}
- {% if a.summary %} -
{{ a.summary | striptags }}
- {% endif %} -
- - {% if actor %} -
- {% if a.actor_url in (followed_urls or []) %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-{% endfor %} - -{% if actors | length >= 20 %} -
-
-{% endif %} diff --git a/shared/browser/templates/social/_timeline_items.html b/shared/browser/templates/social/_timeline_items.html deleted file mode 100644 index b30ee30..0000000 --- a/shared/browser/templates/social/_timeline_items.html +++ /dev/null @@ -1,13 +0,0 @@ -{% for item in items %} - {% include "social/_post_card.html" %} -{% endfor %} - -{% if items %} - {% set last = items[-1] %} - {% if timeline_type == "actor" %} -
-
- {% endif %} -{% endif %} diff --git a/shared/browser/templates/social/actor_timeline.html b/shared/browser/templates/social/actor_timeline.html deleted file mode 100644 index ed34232..0000000 --- a/shared/browser/templates/social/actor_timeline.html +++ /dev/null @@ -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 %} -
-
- {% if remote_actor.icon_url %} - - {% else %} -
- {{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }} -
- {% endif %} - -
-

{{ remote_actor.display_name or remote_actor.preferred_username }}

-
@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}
- {% if remote_actor.summary %} -
{{ remote_actor.summary | safe }}
- {% endif %} -
- - {% if actor %} -
- {% if is_following %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-
- -
- {% set timeline_type = "actor" %} - {% set actor_id = remote_actor.id %} - {% include "social/_timeline_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/followers.html b/shared/browser/templates/social/followers.html deleted file mode 100644 index 9421537..0000000 --- a/shared/browser/templates/social/followers.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Followers — Rose Ash{% endblock %} - -{% block social_content %} -

Followers ({{ total }})

- -
- {% set list_type = "followers" %} - {% include "social/_actor_list_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/following.html b/shared/browser/templates/social/following.html deleted file mode 100644 index 5643b94..0000000 --- a/shared/browser/templates/social/following.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Following — Rose Ash{% endblock %} - -{% block social_content %} -

Following ({{ total }})

- -
- {% set list_type = "following" %} - {% set followed_urls = [] %} - {% include "social/_actor_list_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/index.html b/shared/browser/templates/social/index.html deleted file mode 100644 index 4d32685..0000000 --- a/shared/browser/templates/social/index.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Social — Rose Ash{% endblock %} - -{% block social_content %} -

Social

- -{% if actor %} -
- -
Search
-
Find and follow accounts on the fediverse
-
- -
Following
-
Accounts you follow
-
- -
Followers
-
Accounts following you here
-
- -
Hub
-
Full social experience — timeline, compose, notifications
-
-
-{% else %} -

- Search for accounts on the fediverse, or visit the - Hub to get started. -

-{% endif %} -{% endblock %} diff --git a/shared/browser/templates/social/search.html b/shared/browser/templates/social/search.html deleted file mode 100644 index 682aadc..0000000 --- a/shared/browser/templates/social/search.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Search — Rose Ash{% endblock %} - -{% block social_content %} -

Search

- -
-
- - -
-
- -{% if query and total %} -

{{ total }} result{{ 's' if total != 1 }} for {{ query }}

-{% elif query %} -

No results found for {{ query }}

-{% endif %} - -
- {% include "social/_search_results.html" %} -
-{% endblock %} diff --git a/shared/infrastructure/activitypub.py b/shared/infrastructure/activitypub.py index 1d43209..b8cead2 100644 --- a/shared/infrastructure/activitypub.py +++ b/shared/infrastructure/activitypub.py @@ -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}") diff --git a/shared/infrastructure/ap_social.py b/shared/infrastructure/ap_social.py index a6c1cb4..ba9c0c2 100644 --- a/shared/infrastructure/ap_social.py +++ b/shared/infrastructure/ap_social.py @@ -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//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 diff --git a/shared/infrastructure/ap_social_sx.py b/shared/infrastructure/ap_social_sx.py new file mode 100644 index 0000000..54e76e6 --- /dev/null +++ b/shared/infrastructure/ap_social_sx.py @@ -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}))' + ) diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 4799943..c26d4d4 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -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) --- diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index eb4d0b9..559db89 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -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): diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 676af03..ae17a28 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -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, }