Python no longer generates s-expression strings. All SX rendering now goes through render_to_sx() which builds AST from native Python values and evaluates via async_eval_to_sx() — no SX string literals in Python. - Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py - Add (abort status msg) IO primitive in shared/sx/primitives_io.py - Convert all 9 services: ~650 sx_call() invocations replaced - Convert shared helpers (root_header_sx, full_page_sx, etc.) to async - Fix likes service import bug (likes.models → models) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
593 lines
23 KiB
Python
593 lines
23 KiB
Python
"""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 (
|
|
root_header_sx, oob_header_sx,
|
|
mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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})))'
|
|
)
|
|
|
|
|
|
async def _social_full_headers(ctx: dict, **kw: Any) -> str:
|
|
root_hdr = await root_header_sx(ctx)
|
|
actor = kw.get("actor")
|
|
social_row = _social_header_row(actor)
|
|
return "(<> " + root_hdr + " " + social_row + ")"
|
|
|
|
|
|
async def _social_oob_headers(ctx: dict, **kw: Any) -> str:
|
|
root_hdr = await root_header_sx(ctx)
|
|
actor = kw.get("actor")
|
|
social_row = _social_header_row(actor)
|
|
rows = "(<> " + root_hdr + " " + social_row + ")"
|
|
return await oob_header_sx("root-header-child", "social-lite-header-child", rows)
|
|
|
|
|
|
async def _social_mobile(ctx: dict, **kw: Any) -> str:
|
|
return mobile_menu_sx(await 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}))'
|
|
)
|