"""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}))' )