Move SX construction from Python to .sx defcomps (phases 0-4)
Eliminate Python s-expression string building across account, orders, federation, and cart services. Visual rendering logic now lives entirely in .sx defcomp components; Python files contain only data serialization, header/layout wiring, and thin wrappers that call defcomps. Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/ pluralize/escape/route-prefix primitives. Phase 1: Account — dashboard, newsletters, login/device/check-email content. Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps. Phase 3: Federation — social nav, post cards, timeline, search, actors, notifications, compose, profile assembled defcomps. Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin, payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Federation service s-expression page components.
|
||||
|
||||
Renders social timeline, compose, search, following/followers, notifications,
|
||||
actor profiles, login, and username selection pages.
|
||||
Page helpers now call assembled defcomps in .sx files. This file contains
|
||||
only functions still called directly from route handlers: full-page renders
|
||||
(login, choose-username, profile) and POST fragment renderers (interaction
|
||||
buttons, actor cards, pagination items).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,63 +25,69 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social header nav
|
||||
# Serialization helpers (shared with pages/__init__.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _social_nav_sx(actor: Any) -> str:
|
||||
"""Build the social header nav bar content."""
|
||||
from quart import url_for, request
|
||||
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
if not actor:
|
||||
choose_url = url_for("identity.choose_username_form")
|
||||
return sx_call("federation-nav-choose-username", url=choose_url)
|
||||
|
||||
links = [
|
||||
("social.defpage_home_timeline", "Timeline"),
|
||||
("social.defpage_public_timeline", "Public"),
|
||||
("social.defpage_compose_form", "Compose"),
|
||||
("social.defpage_following_list", "Following"),
|
||||
("social.defpage_followers_list", "Followers"),
|
||||
("social.defpage_search", "Search"),
|
||||
]
|
||||
|
||||
parts = []
|
||||
for endpoint, label in links:
|
||||
href = url_for(endpoint)
|
||||
bold = " font-bold" if request.path == href else ""
|
||||
cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}"
|
||||
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
|
||||
|
||||
# Notifications with live badge
|
||||
notif_url = url_for("social.defpage_notifications")
|
||||
notif_count_url = url_for("social.notification_count")
|
||||
notif_bold = " font-bold" if request.path == notif_url else ""
|
||||
parts.append(sx_call(
|
||||
"federation-nav-notification-link",
|
||||
href=notif_url,
|
||||
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
|
||||
count_url=notif_count_url,
|
||||
))
|
||||
|
||||
# Profile link
|
||||
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
|
||||
parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})')
|
||||
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("federation-nav-bar", items=SxExpr(items_sx))
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"summary": getattr(actor, "summary", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
"domain": getattr(actor, "domain", ""),
|
||||
}
|
||||
|
||||
|
||||
def _social_header_sx(actor: Any) -> str:
|
||||
"""Build the social section header row."""
|
||||
nav_sx = _social_nav_sx(actor)
|
||||
return sx_call("federation-social-header", nav=SxExpr(nav_sx))
|
||||
def _serialize_timeline_item(item) -> dict:
|
||||
published = getattr(item, "published", None)
|
||||
return {
|
||||
"object_id": getattr(item, "object_id", "") or "",
|
||||
"author_inbox": getattr(item, "author_inbox", "") or "",
|
||||
"actor_icon": getattr(item, "actor_icon", None),
|
||||
"actor_name": getattr(item, "actor_name", "?"),
|
||||
"actor_username": getattr(item, "actor_username", ""),
|
||||
"actor_domain": getattr(item, "actor_domain", ""),
|
||||
"content": getattr(item, "content", ""),
|
||||
"summary": getattr(item, "summary", None),
|
||||
"published": published.strftime("%b %d, %H:%M") if published else "",
|
||||
"before_cursor": published.isoformat() if published else "",
|
||||
"url": getattr(item, "url", None),
|
||||
"post_type": getattr(item, "post_type", ""),
|
||||
"boosted_by": getattr(item, "boosted_by", None),
|
||||
"like_count": getattr(item, "like_count", 0) or 0,
|
||||
"boost_count": getattr(item, "boost_count", 0) or 0,
|
||||
"liked_by_me": getattr(item, "liked_by_me", False),
|
||||
"boosted_by_me": getattr(item, "boosted_by_me", False),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_remote_actor(a) -> dict:
|
||||
return {
|
||||
"id": getattr(a, "id", None),
|
||||
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
|
||||
"preferred_username": getattr(a, "preferred_username", ""),
|
||||
"domain": getattr(a, "domain", ""),
|
||||
"icon_url": getattr(a, "icon_url", None),
|
||||
"actor_url": getattr(a, "actor_url", ""),
|
||||
"summary": getattr(a, "summary", None),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social page shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _social_page(ctx: dict, actor: Any, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
"""Render a social page with header and content."""
|
||||
actor_data = _serialize_actor(actor)
|
||||
nav = sx_call("federation-social-nav",
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None)
|
||||
social_hdr = sx_call("federation-social-header", nav=SxExpr(nav))
|
||||
hdr = root_header_sx(ctx)
|
||||
social_hdr = _social_header_sx(actor)
|
||||
child = header_child_sx(social_hdr)
|
||||
header_rows = "(<> " + hdr + " " + child + ")"
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
@@ -87,562 +95,32 @@ def _social_page(ctx: dict, actor: Any, *, content: str,
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _interaction_buttons_sx(item: Any, actor: Any) -> str:
|
||||
"""Render like/boost/reply buttons for a post."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
oid = getattr(item, "object_id", "") or ""
|
||||
ainbox = getattr(item, "author_inbox", "") or ""
|
||||
lcount = getattr(item, "like_count", 0) or 0
|
||||
bcount = getattr(item, "boost_count", 0) or 0
|
||||
liked = getattr(item, "liked_by_me", False)
|
||||
boosted = getattr(item, "boosted_by_me", False)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||
target = f"#interactions-{safe_id}"
|
||||
|
||||
if liked:
|
||||
like_action = url_for("social.unlike")
|
||||
like_cls = "text-red-500 hover:text-red-600"
|
||||
like_icon = "\u2665"
|
||||
else:
|
||||
like_action = url_for("social.like")
|
||||
like_cls = "hover:text-red-500"
|
||||
like_icon = "\u2661"
|
||||
|
||||
if boosted:
|
||||
boost_action = url_for("social.unboost")
|
||||
boost_cls = "text-green-600 hover:text-green-700"
|
||||
else:
|
||||
boost_action = url_for("social.boost")
|
||||
boost_cls = "hover:text-green-600"
|
||||
|
||||
reply_url = url_for("social.defpage_compose_form", reply_to=oid) if oid else ""
|
||||
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = sx_call(
|
||||
"federation-like-form",
|
||||
action=like_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(lcount),
|
||||
)
|
||||
|
||||
boost_form = sx_call(
|
||||
"federation-boost-form",
|
||||
action=boost_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(bcount),
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _post_card_sx(item: Any, actor: Any) -> str:
|
||||
"""Render a single timeline post card."""
|
||||
boosted_by = getattr(item, "boosted_by", None)
|
||||
actor_icon = getattr(item, "actor_icon", None)
|
||||
actor_name = getattr(item, "actor_name", "?")
|
||||
actor_username = getattr(item, "actor_username", "")
|
||||
actor_domain = getattr(item, "actor_domain", "")
|
||||
content = getattr(item, "content", "")
|
||||
summary = getattr(item, "summary", None)
|
||||
published = getattr(item, "published", None)
|
||||
url = getattr(item, "url", None)
|
||||
post_type = getattr(item, "post_type", "")
|
||||
|
||||
boost_sx = sx_call(
|
||||
"federation-boost-label", name=str(escape(boosted_by)),
|
||||
) if boosted_by else ""
|
||||
|
||||
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=actor_icon or None,
|
||||
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
|
||||
initial=None if actor_icon else initial,
|
||||
)
|
||||
|
||||
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
|
||||
time_str = published.strftime("%b %d, %H:%M") if published else ""
|
||||
|
||||
if summary:
|
||||
content_sx = sx_call(
|
||||
"federation-content",
|
||||
content=content, summary=str(escape(summary)),
|
||||
)
|
||||
else:
|
||||
content_sx = sx_call("federation-content", content=content)
|
||||
|
||||
original_sx = ""
|
||||
if url and post_type == "remote":
|
||||
original_sx = sx_call("federation-original-link", url=url)
|
||||
|
||||
interactions_sx = ""
|
||||
if actor:
|
||||
oid = getattr(item, "object_id", "") or ""
|
||||
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
|
||||
|
||||
return sx_call(
|
||||
"federation-post-card",
|
||||
boost=SxExpr(boost_sx) if boost_sx else None,
|
||||
avatar=SxExpr(avatar),
|
||||
actor_name=str(escape(actor_name)),
|
||||
actor_username=str(escape(actor_username)),
|
||||
domain=domain_str, time=time_str,
|
||||
content=SxExpr(content_sx),
|
||||
original=SxExpr(original_sx) if original_sx else None,
|
||||
interactions=SxExpr(interactions_sx) if interactions_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeline items (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
|
||||
actor_id: int | None = None) -> str:
|
||||
"""Render timeline items with infinite scroll sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_post_card_sx(item, actor) for item in items]
|
||||
|
||||
if items:
|
||||
last = items[-1]
|
||||
before = last.published.isoformat() if last.published else ""
|
||||
if timeline_type == "actor" and actor_id is not None:
|
||||
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
|
||||
else:
|
||||
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search results (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "search") -> str:
|
||||
"""Render a single actor card with follow/unfollow button."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
|
||||
username = getattr(a, "preferred_username", "")
|
||||
domain = getattr(a, "domain", "")
|
||||
icon_url = getattr(a, "icon_url", None)
|
||||
actor_url = getattr(a, "actor_url", "")
|
||||
summary = getattr(a, "summary", None)
|
||||
aid = getattr(a, "id", None)
|
||||
|
||||
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||
|
||||
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=icon_url or None,
|
||||
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
|
||||
initial=None if icon_url else initial,
|
||||
)
|
||||
|
||||
# Name link
|
||||
if (list_type in ("following", "search")) and aid:
|
||||
name_sx = sx_call(
|
||||
"federation-actor-name-link",
|
||||
href=url_for("social.defpage_actor_timeline", id=aid),
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
else:
|
||||
name_sx = sx_call(
|
||||
"federation-actor-name-link-external",
|
||||
href=f"https://{domain}/@{username}",
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
|
||||
summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else ""
|
||||
|
||||
# Follow/unfollow button
|
||||
button_sx = ""
|
||||
if actor:
|
||||
is_followed = actor_url in (followed_urls or set())
|
||||
if list_type == "following" or is_followed:
|
||||
button_sx = sx_call(
|
||||
"federation-unfollow-button",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
)
|
||||
else:
|
||||
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||
button_sx = sx_call(
|
||||
"federation-follow-button",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-actor-card",
|
||||
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
|
||||
id=f"actor-{safe_id}",
|
||||
avatar=SxExpr(avatar),
|
||||
name=SxExpr(name_sx),
|
||||
username=str(escape(username)), domain=str(escape(domain)),
|
||||
summary=SxExpr(summary_sx) if summary_sx else None,
|
||||
button=SxExpr(button_sx) if button_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _search_results_sx(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Render search results with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors]
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
def _actor_list_items_sx(actors: list, page: int, list_type: str,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Render actor list items (following/followers) with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors]
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _notification_sx(notif: Any) -> str:
|
||||
"""Render a single notification."""
|
||||
from_name = getattr(notif, "from_actor_name", "?")
|
||||
from_username = getattr(notif, "from_actor_username", "")
|
||||
from_domain = getattr(notif, "from_actor_domain", "")
|
||||
from_icon = getattr(notif, "from_actor_icon", None)
|
||||
ntype = getattr(notif, "notification_type", "")
|
||||
preview = getattr(notif, "target_content_preview", None)
|
||||
created = getattr(notif, "created_at", None)
|
||||
read = getattr(notif, "read", True)
|
||||
app_domain = getattr(notif, "app_domain", "")
|
||||
|
||||
border = " border-l-4 border-l-stone-400" if not read else ""
|
||||
|
||||
initial = from_name[0].upper() if (not from_icon and from_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=from_icon or None,
|
||||
cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
|
||||
initial=None if from_icon else initial,
|
||||
)
|
||||
|
||||
domain_str = f"@{escape(from_domain)}" if from_domain else ""
|
||||
|
||||
type_map = {
|
||||
"follow": "followed you",
|
||||
"like": "liked your post",
|
||||
"boost": "boosted your post",
|
||||
"mention": "mentioned you",
|
||||
"reply": "replied to your post",
|
||||
}
|
||||
action = type_map.get(ntype, "")
|
||||
if ntype == "follow" and app_domain and app_domain != "federation":
|
||||
action += f" on {escape(app_domain)}"
|
||||
|
||||
preview_sx = sx_call(
|
||||
"federation-notification-preview", preview=str(escape(preview)),
|
||||
) if preview else ""
|
||||
time_str = created.strftime("%b %d, %H:%M") if created else ""
|
||||
|
||||
return sx_call(
|
||||
"federation-notification-card",
|
||||
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
|
||||
avatar=SxExpr(avatar),
|
||||
from_name=str(escape(from_name)),
|
||||
from_username=str(escape(from_username)),
|
||||
from_domain=domain_str, action_text=action,
|
||||
preview=SxExpr(preview_sx) if preview_sx else None,
|
||||
time=time_str,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Home page
|
||||
# Public API: Full page renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_federation_home(ctx: dict) -> str:
|
||||
"""Full page: federation home (minimal)."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: federation login form."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
content = sx_call(
|
||||
"auth-login-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action, csrf_token=csrf,
|
||||
email=str(escape(email)),
|
||||
)
|
||||
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Login \u2014 Rose Ash")
|
||||
content = sx_call("account-login-content",
|
||||
error=error or None, email=str(escape(email)))
|
||||
return _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash")
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
"""Full page: check email after magic link sent."""
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
|
||||
error_sx = sx_call(
|
||||
"auth-check-email-error", error=str(escape(email_error)),
|
||||
) if email_error else ""
|
||||
|
||||
content = sx_call(
|
||||
"auth-check-email",
|
||||
email=str(escape(email)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
)
|
||||
|
||||
content = sx_call("account-check-email-content",
|
||||
email=str(escape(email)), email_error=email_error)
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Check your email \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content builders (used by defpage before_request)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
|
||||
"""Build timeline content SX string."""
|
||||
from quart import url_for
|
||||
|
||||
label = "Home" if timeline_type == "home" else "Public"
|
||||
compose_sx = ""
|
||||
if actor:
|
||||
compose_url = url_for("social.defpage_compose_form")
|
||||
compose_sx = sx_call("federation-compose-button", url=compose_url)
|
||||
|
||||
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
|
||||
|
||||
return sx_call(
|
||||
"federation-timeline-page",
|
||||
label=label,
|
||||
compose=SxExpr(compose_sx) if compose_sx else None,
|
||||
timeline=SxExpr(timeline_sx) if timeline_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_timeline_items(items: list, timeline_type: str,
|
||||
actor: Any, actor_id: int | None = None) -> str:
|
||||
"""Pagination fragment: timeline items."""
|
||||
return _timeline_items_sx(items, timeline_type, actor, actor_id)
|
||||
|
||||
|
||||
def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
|
||||
"""Build compose form content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
action = url_for("social.compose_submit")
|
||||
|
||||
reply_sx = ""
|
||||
if reply_to:
|
||||
reply_sx = sx_call(
|
||||
"federation-compose-reply",
|
||||
reply_to=str(escape(reply_to)),
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-compose-form",
|
||||
action=action, csrf=csrf,
|
||||
reply=SxExpr(reply_sx) if reply_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _search_content_sx(query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Build search page content SX string."""
|
||||
from quart import url_for
|
||||
|
||||
search_url = url_for("social.defpage_search")
|
||||
search_page_url = url_for("social.search_page")
|
||||
|
||||
results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
|
||||
|
||||
info_sx = ""
|
||||
if query and total:
|
||||
s = "s" if total != 1 else ""
|
||||
info_sx = sx_call(
|
||||
"federation-search-info",
|
||||
cls="text-sm text-stone-500 mb-4",
|
||||
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
elif query:
|
||||
info_sx = sx_call(
|
||||
"federation-search-info",
|
||||
cls="text-stone-500 mb-4",
|
||||
text=f"No results found for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-search-page",
|
||||
search_url=search_url, search_page_url=search_page_url,
|
||||
query=str(escape(query)),
|
||||
info=SxExpr(info_sx) if info_sx else None,
|
||||
results=SxExpr(results_sx) if results_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_search_results(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Pagination fragment: search results."""
|
||||
return _search_results_sx(actors, query, page, followed_urls, actor)
|
||||
|
||||
|
||||
def _following_content_sx(actors: list, total: int, actor: Any) -> str:
|
||||
"""Build following list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Following", count_str=f"({total})",
|
||||
items=SxExpr(items_sx) if items_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
||||
"""Pagination fragment: following items."""
|
||||
return _actor_list_items_sx(actors, page, "following", set(), actor)
|
||||
|
||||
|
||||
def _followers_content_sx(actors: list, total: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Build followers list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Followers", count_str=f"({total})",
|
||||
items=SxExpr(items_sx) if items_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_followers_items(actors: list, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Pagination fragment: followers items."""
|
||||
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
|
||||
|
||||
|
||||
def _actor_timeline_content_sx(remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Build actor timeline content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
display_name = remote_actor.display_name or remote_actor.preferred_username
|
||||
icon_url = getattr(remote_actor, "icon_url", None)
|
||||
summary = getattr(remote_actor, "summary", None)
|
||||
actor_url = getattr(remote_actor, "actor_url", "")
|
||||
|
||||
initial = display_name[0].upper() if (not icon_url and display_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=icon_url or None,
|
||||
cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
|
||||
initial=None if icon_url else initial,
|
||||
)
|
||||
|
||||
summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else ""
|
||||
|
||||
follow_sx = ""
|
||||
if actor:
|
||||
if is_following:
|
||||
follow_sx = sx_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Unfollow",
|
||||
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
|
||||
)
|
||||
else:
|
||||
follow_sx = sx_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Follow",
|
||||
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
|
||||
timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id)
|
||||
|
||||
header_sx = sx_call(
|
||||
"federation-actor-profile-header",
|
||||
avatar=SxExpr(avatar),
|
||||
display_name=str(escape(display_name)),
|
||||
username=str(escape(remote_actor.preferred_username)),
|
||||
domain=str(escape(remote_actor.domain)),
|
||||
summary=SxExpr(summary_sx) if summary_sx else None,
|
||||
follow=SxExpr(follow_sx) if follow_sx else None,
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-actor-timeline-layout",
|
||||
header=SxExpr(header_sx),
|
||||
timeline=SxExpr(timeline_sx) if timeline_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_actor_timeline_items(items: list, actor_id: int,
|
||||
actor: Any) -> str:
|
||||
"""Pagination fragment: actor timeline items."""
|
||||
return _timeline_items_sx(items, "actor", actor, actor_id)
|
||||
|
||||
|
||||
def _notifications_content_sx(notifications: list) -> str:
|
||||
"""Build notifications content SX string."""
|
||||
if not notifications:
|
||||
notif_sx = sx_call("empty-state", message="No notifications yet.",
|
||||
cls="text-stone-500")
|
||||
else:
|
||||
items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")"
|
||||
notif_sx = sx_call(
|
||||
"federation-notifications-list",
|
||||
items=SxExpr(items_sx),
|
||||
)
|
||||
|
||||
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Choose username
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_choose_username_page(ctx: dict) -> str:
|
||||
"""Full page: choose username form."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
from shared.config import config
|
||||
@@ -655,7 +133,6 @@ async def render_choose_username_page(ctx: dict) -> str:
|
||||
actor = ctx.get("actor")
|
||||
|
||||
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
content = sx_call(
|
||||
"federation-choose-username",
|
||||
domain=str(escape(ap_domain)),
|
||||
@@ -663,18 +140,12 @@ async def render_choose_username_page(ctx: dict) -> str:
|
||||
csrf=csrf, username=str(escape(username)),
|
||||
check_url=check_url,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Choose Username \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Actor profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_profile_page(ctx: dict, actor: Any, activities: list,
|
||||
total: int) -> str:
|
||||
"""Full page: actor profile."""
|
||||
from shared.config import config
|
||||
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
@@ -710,11 +181,95 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
|
||||
activities_heading=f"Activities ({total})",
|
||||
activities=SxExpr(activities_sx),
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title=f"@{actor.preferred_username} \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Pagination fragment renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_timeline_items(items: list, timeline_type: str,
|
||||
actor: Any, actor_id: int | None = None) -> str:
|
||||
from quart import url_for
|
||||
item_dicts = [_serialize_timeline_item(i) for i in items]
|
||||
actor_data = _serialize_actor(actor)
|
||||
|
||||
# Build next URL
|
||||
next_url = None
|
||||
if items:
|
||||
last = items[-1]
|
||||
before = last.published.isoformat() if last.published else ""
|
||||
if timeline_type == "actor" and actor_id is not None:
|
||||
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
|
||||
else:
|
||||
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
|
||||
|
||||
return sx_call("federation-timeline-items",
|
||||
items=SxExpr(serialize(item_dicts)),
|
||||
timeline_type=timeline_type,
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None,
|
||||
next_url=next_url)
|
||||
|
||||
|
||||
async def render_search_results(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(sx_call("federation-actor-card-from-data",
|
||||
a=SxExpr(serialize(ad)),
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None,
|
||||
followed_urls=SxExpr(serialize(list(followed_urls))),
|
||||
list_type="search"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(sx_call("federation-actor-card-from-data",
|
||||
a=SxExpr(serialize(ad)),
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None,
|
||||
followed_urls=SxExpr(serialize([])),
|
||||
list_type="following"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.following_list_page", page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_followers_items(actors: list, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(sx_call("federation-actor-card-from-data",
|
||||
a=SxExpr(serialize(ad)),
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None,
|
||||
followed_urls=SxExpr(serialize(list(followed_urls))),
|
||||
list_type="followers"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.followers_list_page", page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_actor_timeline_items(items: list, actor_id: int,
|
||||
actor: Any) -> str:
|
||||
return await render_timeline_items(items, "actor", actor, actor_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: POST handler fragment renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -723,20 +278,56 @@ def render_interaction_buttons(object_id: str, author_inbox: str,
|
||||
like_count: int, boost_count: int,
|
||||
liked_by_me: bool, boosted_by_me: bool,
|
||||
actor: Any) -> str:
|
||||
"""Render interaction buttons fragment for HTMX POST response."""
|
||||
from types import SimpleNamespace
|
||||
item = SimpleNamespace(
|
||||
object_id=object_id,
|
||||
author_inbox=author_inbox,
|
||||
like_count=like_count,
|
||||
boost_count=boost_count,
|
||||
liked_by_me=liked_by_me,
|
||||
boosted_by_me=boosted_by_me,
|
||||
)
|
||||
return _interaction_buttons_sx(item, actor)
|
||||
"""Render interaction buttons fragment for POST response."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
safe_id = object_id.replace("/", "_").replace(":", "_")
|
||||
target = f"#interactions-{safe_id}"
|
||||
|
||||
if liked_by_me:
|
||||
like_action = url_for("social.unlike")
|
||||
like_cls = "text-red-500 hover:text-red-600"
|
||||
like_icon = "\u2665"
|
||||
else:
|
||||
like_action = url_for("social.like")
|
||||
like_cls = "hover:text-red-500"
|
||||
like_icon = "\u2661"
|
||||
|
||||
if boosted_by_me:
|
||||
boost_action = url_for("social.unboost")
|
||||
boost_cls = "text-green-600 hover:text-green-700"
|
||||
else:
|
||||
boost_action = url_for("social.boost")
|
||||
boost_cls = "hover:text-green-600"
|
||||
|
||||
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
|
||||
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = sx_call("federation-like-form",
|
||||
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(like_count))
|
||||
|
||||
boost_form = sx_call("federation-boost-form",
|
||||
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(boost_count))
|
||||
|
||||
return sx_call("federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None)
|
||||
|
||||
|
||||
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "following") -> str:
|
||||
"""Render a single actor card fragment for HTMX POST response."""
|
||||
return _actor_card_sx(actor_dto, actor, followed_urls, list_type=list_type)
|
||||
"""Render a single actor card fragment for POST response."""
|
||||
actor_data = _serialize_actor(actor)
|
||||
ad = _serialize_remote_actor(actor_dto)
|
||||
return sx_call("federation-actor-card-from-data",
|
||||
a=SxExpr(serialize(ad)),
|
||||
actor=SxExpr(serialize(actor_data)) if actor_data else None,
|
||||
followed_urls=SxExpr(serialize(list(followed_urls))),
|
||||
list_type=list_type)
|
||||
|
||||
Reference in New Issue
Block a user