Externalize sexp to .sexpr files + render() API

Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:14:58 +00:00
parent f4c2f4b6b8
commit f9d9697c67
64 changed files with 5041 additions and 4051 deletions

View File

@@ -422,8 +422,9 @@ def register(url_prefix="/social"):
return Response("0", content_type="text/plain")
count = await services.federation.unread_notification_count(g.s, actor.id)
if count > 0:
from shared.sexp.jinja_bridge import render as render_comp
return Response(
f'<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5">{count}</span>',
render_comp("notification-badge", count=str(count)),
content_type="text/html",
)
return Response("", content_type="text/html")

View File

@@ -0,0 +1,52 @@
;; Auth components (login, check email, choose username)
(defcomp ~federation-error-banner (&key error)
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! error)))
(defcomp ~federation-login-form (&key error-html action csrf email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html)
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
(defcomp ~federation-check-email-error (&key error)
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! error)))
(defcomp ~federation-check-email (&key email error-html)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
(raw! error-html)))
(defcomp ~federation-choose-username (&key domain error-html csrf username check-url)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-2" "Choose your username")
(p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "
(strong "@username@" (raw! domain)))
(raw! error-html)
(form :method "post" :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div
(label :for "username" :class "block text-sm font-medium mb-1" "Username")
(div :class "flex items-center"
(span :class "text-stone-400 mr-1" "@")
(input :type "text" :name "username" :id "username" :value username
:pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"
:required true :autocomplete "off"
:class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
:hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"
:hx-include "[name='username']"))
(div :id "username-status" :class "text-sm mt-1")
(p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Claim username"))))

View File

@@ -0,0 +1,25 @@
;; Notification components
(defcomp ~federation-notification-preview (&key preview)
(div :class "text-sm text-stone-500 mt-1 truncate" (raw! preview)))
(defcomp ~federation-notification-card (&key cls avatar-html from-name from-username from-domain action-text preview-html time-html)
(div :class cls
(div :class "flex items-start gap-3"
(raw! avatar-html)
(div :class "flex-1"
(div :class "text-sm"
(span :class "font-semibold" (raw! from-name))
" " (span :class "text-stone-500" "@" (raw! from-username) (raw! from-domain))
" " (span :class "text-stone-600" (raw! action-text)))
(raw! preview-html)
(div :class "text-xs text-stone-400 mt-1" (raw! time-html))))))
(defcomp ~federation-notifications-empty ()
(p :class "text-stone-500" "No notifications yet."))
(defcomp ~federation-notifications-list (&key items-html)
(div :class "space-y-2" (raw! items-html)))
(defcomp ~federation-notifications-page (&key notifs-html)
(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs-html))

View File

@@ -0,0 +1,55 @@
;; Profile and actor timeline components
(defcomp ~federation-actor-profile-header (&key avatar-html display-name username domain summary-html follow-html)
(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"
(div :class "flex items-center gap-4"
(raw! avatar-html)
(div :class "flex-1"
(h1 :class "text-xl font-bold" (raw! display-name))
(div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(raw! follow-html))))
(defcomp ~federation-actor-timeline-layout (&key header-html timeline-html)
(raw! header-html)
(div :id "timeline" (raw! timeline-html)))
(defcomp ~federation-follow-form (&key action csrf actor-url label cls)
(div :class "flex-shrink-0"
(form :method "post" :action action
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class cls (raw! label)))))
(defcomp ~federation-profile-summary (&key summary)
(div :class "text-sm text-stone-600 mt-2" (raw! summary)))
;; Public profile page
(defcomp ~federation-activity-obj-type (&key obj-type)
(span :class "text-sm text-stone-500" (raw! obj-type)))
(defcomp ~federation-activity-card (&key activity-type published obj-type-html)
(div :class "bg-white rounded-lg shadow p-4"
(div :class "flex justify-between items-start"
(span :class "font-medium" (raw! activity-type))
(span :class "text-sm text-stone-400" (raw! published)))
(raw! obj-type-html)))
(defcomp ~federation-activities-list (&key items-html)
(div :class "space-y-4" (raw! items-html)))
(defcomp ~federation-activities-empty ()
(p :class "text-stone-500" "No activities yet."))
(defcomp ~federation-profile-page (&key display-name username domain summary-html activities-heading activities-html)
(div :class "py-8"
(div :class "bg-white rounded-lg shadow p-6 mb-6"
(h1 :class "text-2xl font-bold" (raw! display-name))
(p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(h2 :class "text-xl font-bold mb-4" (raw! activities-heading))
(raw! activities-html)))
(defcomp ~federation-profile-summary-text (&key text)
(p :class "mt-2" (raw! text)))

View File

@@ -0,0 +1,61 @@
;; Search and actor card components
(defcomp ~federation-actor-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(defcomp ~federation-actor-avatar-placeholder (&key cls initial)
(div :class cls (raw! initial)))
(defcomp ~federation-actor-name-link (&key href name)
(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name)))
(defcomp ~federation-actor-name-link-external (&key href name)
(a :href href :target "_blank" :rel "noopener"
:class "font-semibold text-stone-900 hover:underline" (raw! name)))
(defcomp ~federation-actor-summary (&key summary)
(div :class "text-sm text-stone-600 mt-1 truncate" (raw! summary)))
(defcomp ~federation-unfollow-button (&key action csrf actor-url)
(div :class "flex-shrink-0"
(form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow"))))
(defcomp ~federation-follow-button (&key action csrf actor-url label)
(div :class "flex-shrink-0"
(form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label)))))
(defcomp ~federation-actor-card (&key cls id avatar-html name-html username domain summary-html button-html)
(article :class cls :id id
(raw! avatar-html)
(div :class "flex-1 min-w-0"
(raw! name-html)
(div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(raw! button-html)))
(defcomp ~federation-search-info (&key cls text)
(p :class cls (raw! text)))
(defcomp ~federation-search-page (&key search-url search-page-url query info-html results-html)
(h1 :class "text-2xl font-bold mb-6" "Search")
(form :method "get" :action search-url :class "mb-6"
:hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url
(div :class "flex gap-2"
(input :type "text" :name "q" :value query
:class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
:placeholder "Search users or @user@instance.tld")
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))
(raw! info-html)
(div :id "search-results" (raw! results-html)))
;; Following / Followers list page
(defcomp ~federation-actor-list-page (&key title count-str items-html)
(h1 :class "text-2xl font-bold mb-6" (raw! title) " "
(span :class "text-stone-400 font-normal" (raw! count-str)))
(div :id "actor-list" (raw! items-html)))

View File

@@ -6,12 +6,16 @@ actor profiles, login, and username selection pages.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import sexp
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import root_header_html, full_page
# Load federation-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# ---------------------------------------------------------------------------
# Social header nav
@@ -23,11 +27,7 @@ def _social_nav_html(actor: Any) -> str:
if not actor:
choose_url = url_for("identity.choose_username_form")
return sexp(
'(nav :class "flex gap-3 text-sm items-center"'
' (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))',
url=choose_url,
)
return render("federation-nav-choose-username", url=choose_url)
links = [
("social.home_timeline", "Timeline"),
@@ -42,8 +42,8 @@ def _social_nav_html(actor: Any) -> str:
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(sexp(
'(a :href href :class cls (raw! label))',
parts.append(render(
"federation-nav-link",
href=href,
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
label=label,
@@ -53,47 +53,38 @@ def _social_nav_html(actor: Any) -> str:
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(sexp(
'(a :href href :class cls "Notifications"'
' (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"'
' :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))',
href=notif_url, cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
**{"count-url": notif_count_url},
parts.append(render(
"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(sexp(
'(a :href href :class "px-2 py-1 rounded hover:bg-stone-200" (raw! label))',
href=profile_url, label=f"@{actor.preferred_username}",
parts.append(render(
"federation-nav-link",
href=profile_url,
cls="px-2 py-1 rounded hover:bg-stone-200",
label=f"@{actor.preferred_username}",
))
return sexp(
'(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))',
items="".join(parts),
)
return render("federation-nav-bar", items_html="".join(parts))
def _social_header_html(actor: Any) -> str:
"""Build the social section header row."""
nav_html = _social_nav_html(actor)
return sexp(
'(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"'
' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))',
nh=nav_html,
)
return render("federation-social-header", nav_html=nav_html)
def _social_page(ctx: dict, actor: Any, *, content_html: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content."""
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))',
sh=_social_header_html(actor),
)
hdr += render("federation-header-child", inner_html=_social_header_html(actor))
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
meta_html=meta_html or sexp('(title (raw! t))', t=escape(title)))
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
@@ -133,37 +124,25 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
reply_html = sexp(
'(a :href url :class "hover:text-stone-700" "Reply")',
url=reply_url,
) if reply_url else ""
reply_html = render("federation-reply-link", url=reply_url) if reply_url else ""
like_form = sexp(
'(form :hx-post action :hx-target target :hx-swap "innerHTML"'
' (input :type "hidden" :name "object_id" :value oid)'
' (input :type "hidden" :name "author_inbox" :value ainbox)'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit" :class cls (span (raw! icon)) " " (raw! count)))',
like_form = render(
"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 = sexp(
'(form :hx-post action :hx-target target :hx-swap "innerHTML"'
' (input :type "hidden" :name "object_id" :value oid)'
' (input :type "hidden" :name "author_inbox" :value ainbox)'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit" :class cls (span "\u21bb") " " (raw! count)))',
boost_form = render(
"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 sexp(
'(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"'
' (raw! like) (raw! boost) (raw! reply))',
like=like_form, boost=boost_form, reply=reply_html,
return render(
"federation-interaction-buttons",
like_html=like_form, boost_html=boost_form, reply_html=reply_html,
)
@@ -180,74 +159,53 @@ def _post_card_html(item: Any, actor: Any) -> str:
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
boost_html = sexp(
'(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))',
name=str(escape(boosted_by)),
boost_html = render(
"federation-boost-label", name=str(escape(boosted_by)),
) if boosted_by else ""
if actor_icon:
avatar = sexp(
'(img :src src :alt "" :class "w-10 h-10 rounded-full")',
src=actor_icon,
)
avatar = render("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = sexp(
'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm" (raw! i))',
i=initial,
avatar = render(
"federation-avatar-placeholder",
cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=initial,
)
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
time_html = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_html = sexp(
'(details :class "mt-2"'
' (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! s))'
' (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c)))',
s=str(escape(summary)), c=content,
content_html = render(
"federation-content-cw",
summary=str(escape(summary)), content=content,
)
else:
content_html = sexp(
'(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))',
c=content,
)
content_html = render("federation-content-plain", content=content)
original_html = ""
if url and post_type == "remote":
original_html = sexp(
'(a :href url :target "_blank" :rel "noopener"'
' :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")',
url=url,
)
original_html = render("federation-original-link", url=url)
interactions_html = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_html = sexp(
'(div :id id (raw! buttons))',
interactions_html = render(
"federation-interactions-wrap",
id=f"interactions-{safe_id}",
buttons=_interaction_buttons_html(item, actor),
buttons_html=_interaction_buttons_html(item, actor),
)
return sexp(
'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
' (raw! boost)'
' (div :class "flex items-start gap-3"'
' (raw! avatar)'
' (div :class "flex-1 min-w-0"'
' (div :class "flex items-baseline gap-2"'
' (span :class "font-semibold text-stone-900" (raw! aname))'
' (span :class "text-sm text-stone-500" "@" (raw! ausername) (raw! domain))'
' (span :class "text-sm text-stone-400 ml-auto" (raw! time)))'
' (raw! content) (raw! original) (raw! interactions))))',
boost=boost_html, avatar=avatar,
aname=str(escape(actor_name)),
ausername=str(escape(actor_username)),
domain=domain_html, time=time_html,
content=content_html, original=original_html,
interactions=interactions_html,
return render(
"federation-post-card",
boost_html=boost_html, avatar_html=avatar,
actor_name=str(escape(actor_name)),
actor_username=str(escape(actor_username)),
domain_html=domain_html, time_html=time_html,
content_html=content_html, original_html=original_html,
interactions_html=interactions_html,
)
@@ -269,10 +227,7 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any,
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(sexp(
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
parts.append(render("federation-scroll-sentinel", url=next_url))
return "".join(parts)
@@ -299,75 +254,54 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url:
avatar = sexp(
'(img :src src :alt "" :class "w-12 h-12 rounded-full")',
src=icon_url,
)
avatar = render("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = sexp(
'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold" (raw! i))',
i=initial,
avatar = render(
"federation-actor-avatar-placeholder",
cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=initial,
)
# Name link
if (list_type in ("following", "search")) and aid:
name_html = sexp(
'(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))',
name_html = render(
"federation-actor-name-link",
href=url_for("social.actor_timeline", id=aid),
name=str(escape(display_name)),
)
else:
name_html = sexp(
'(a :href href :target "_blank" :rel "noopener"'
' :class "font-semibold text-stone-900 hover:underline" (raw! name))',
name_html = render(
"federation-actor-name-link-external",
href=f"https://{domain}/@{username}",
name=str(escape(display_name)),
)
summary_html = sexp(
'(div :class "text-sm text-stone-600 mt-1 truncate" (raw! s))',
s=summary,
) if summary else ""
summary_html = render("federation-actor-summary", summary=summary) if summary else ""
# Follow/unfollow button
button_html = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
button_html = sexp(
'(div :class "flex-shrink-0"'
' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "actor_url" :value aurl)'
' (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))',
action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url,
button_html = render(
"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_html = sexp(
'(div :class "flex-shrink-0"'
' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "actor_url" :value aurl)'
' (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label))))',
action=url_for("social.follow"), csrf=csrf, aurl=actor_url, label=label,
button_html = render(
"federation-follow-button",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
)
return sexp(
'(article :class cls :id id'
' (raw! avatar)'
' (div :class "flex-1 min-w-0"'
' (raw! name-link)'
' (div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))'
' (raw! summary))'
' (raw! button))',
return render(
"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=avatar,
**{"name-link": name_html},
avatar_html=avatar, name_html=name_html,
username=str(escape(username)), domain=str(escape(domain)),
summary=summary_html, button=button_html,
summary_html=summary_html, button_html=button_html,
)
@@ -379,10 +313,7 @@ def _search_results_html(actors: list, query: str, page: int,
parts = [_actor_card_html(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(sexp(
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
parts.append(render("federation-scroll-sentinel", url=next_url))
return "".join(parts)
@@ -394,10 +325,7 @@ def _actor_list_items_html(actors: list, page: int, list_type: str,
parts = [_actor_card_html(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(sexp(
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
parts.append(render("federation-scroll-sentinel", url=next_url))
return "".join(parts)
@@ -420,15 +348,13 @@ def _notification_html(notif: Any) -> str:
border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon:
avatar = sexp(
'(img :src src :alt "" :class "w-8 h-8 rounded-full")',
src=from_icon,
)
avatar = render("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
else:
initial = from_name[0].upper() if from_name else "?"
avatar = sexp(
'(div :class "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs" (raw! i))',
i=initial,
avatar = render(
"federation-avatar-placeholder",
cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=initial,
)
domain_html = f"@{escape(from_domain)}" if from_domain else ""
@@ -444,29 +370,19 @@ def _notification_html(notif: Any) -> str:
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
preview_html = sexp(
'(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))',
p=str(escape(preview)),
preview_html = render(
"federation-notification-preview", preview=str(escape(preview)),
) if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else ""
return sexp(
'(div :class cls'
' (div :class "flex items-start gap-3"'
' (raw! avatar)'
' (div :class "flex-1"'
' (div :class "text-sm"'
' (span :class "font-semibold" (raw! fname))'
' " " (span :class "text-stone-500" "@" (raw! fusername) (raw! fdomain))'
' " " (span :class "text-stone-600" (raw! action)))'
' (raw! preview)'
' (div :class "text-xs text-stone-400 mt-1" (raw! time)))))',
return render(
"federation-notification-card",
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
avatar=avatar,
fname=str(escape(from_name)),
fusername=str(escape(from_username)),
fdomain=domain_html, action=action,
preview=preview_html, time=time_html,
avatar_html=avatar,
from_name=str(escape(from_name)),
from_username=str(escape(from_username)),
from_domain=domain_html, action_text=action,
preview_html=preview_html, time_html=time_html,
)
@@ -494,25 +410,11 @@ async def render_login_page(ctx: dict) -> str:
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_html = sexp(
'(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
e=error,
) if error else ""
error_html = render("federation-error-banner", error=error) if error else ""
content = sexp(
'(div :class "py-8 max-w-md mx-auto"'
' (h1 :class "text-2xl font-bold mb-6" "Sign in")'
' (raw! err)'
' (form :method "post" :action action :class "space-y-4"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div'
' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")'
' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true'
' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))'
' (button :type "submit"'
' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
' "Send magic link")))',
err=error_html, action=action, csrf=csrf,
content = render(
"federation-login-form",
error_html=error_html, action=action, csrf=csrf,
email=str(escape(email)),
)
@@ -526,18 +428,13 @@ async def render_check_email_page(ctx: dict) -> str:
email = ctx.get("email", "")
email_error = ctx.get("email_error")
error_html = sexp(
'(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))',
e=str(escape(email_error)),
error_html = render(
"federation-check-email-error", error=str(escape(email_error)),
) if email_error else ""
content = sexp(
'(div :class "py-8 max-w-md mx-auto text-center"'
' (h1 :class "text-2xl font-bold mb-4" "Check your email")'
' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")'
' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")'
' (raw! err))',
email=str(escape(email)), err=error_html,
content = render(
"federation-check-email",
email=str(escape(email)), error_html=error_html,
)
hdr = root_header_html(ctx)
@@ -558,19 +455,13 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
compose_html = ""
if actor:
compose_url = url_for("social.compose_form")
compose_html = sexp(
'(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")',
url=compose_url,
)
compose_html = render("federation-compose-button", url=compose_url)
timeline_html = _timeline_items_html(items, timeline_type, actor)
content = sexp(
'(div :class "flex items-center justify-between mb-6"'
' (h1 :class "text-2xl font-bold" (raw! label) " Timeline")'
' (raw! compose))'
'(div :id "timeline" (raw! tl))',
label=label, compose=compose_html, tl=timeline_html,
content = render(
"federation-timeline-page",
label=label, compose_html=compose_html, timeline_html=timeline_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -597,27 +488,14 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
reply_html = ""
if reply_to:
reply_html = sexp(
'(input :type "hidden" :name "in_reply_to" :value val)'
'(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! rt)))',
val=str(escape(reply_to)), rt=str(escape(reply_to)),
reply_html = render(
"federation-compose-reply",
reply_to=str(escape(reply_to)),
)
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Compose")'
'(form :method "post" :action action :class "space-y-4"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (raw! reply)'
' (textarea :name "content" :rows "6" :maxlength "5000" :required true'
' :class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
' :placeholder "What\'s on your mind?")'
' (div :class "flex items-center justify-between"'
' (select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"'
' (option :value "public" "Public")'
' (option :value "unlisted" "Unlisted")'
' (option :value "followers" "Followers only"))'
' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))',
action=action, csrf=csrf, reply=reply_html,
content = render(
"federation-compose-form",
action=action, csrf=csrf, reply_html=reply_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -641,30 +519,23 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
info_html = ""
if query and total:
s = "s" if total != 1 else ""
info_html = sexp(
'(p :class "text-sm text-stone-500 mb-4" (raw! t))',
t=f"{total} result{s} for <strong>{escape(query)}</strong>",
info_html = render(
"federation-search-info",
cls="text-sm text-stone-500 mb-4",
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
)
elif query:
info_html = sexp(
'(p :class "text-stone-500 mb-4" (raw! t))',
t=f"No results found for <strong>{escape(query)}</strong>",
info_html = render(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Search")'
'(form :method "get" :action search-url :class "mb-6"'
' :hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url'
' (div :class "flex gap-2"'
' (input :type "text" :name "q" :value query'
' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
' :placeholder "Search users or @user@instance.tld")'
' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))'
'(raw! info)'
'(div :id "search-results" (raw! results))',
**{"search-url": search_url, "search-page-url": search_page_url},
content = render(
"federation-search-page",
search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)),
info=info_html, results=results_html,
info_html=info_html, results_html=results_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -685,11 +556,9 @@ async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str:
"""Full page: following list."""
items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Following "'
' (span :class "text-stone-400 font-normal" (raw! count-str)))'
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({total})"}, items=items_html,
content = render(
"federation-actor-list-page",
title="Following", count_str=f"({total})", items_html=items_html,
)
return _social_page(ctx, actor, content_html=content,
title="Following \u2014 Rose Ash")
@@ -704,11 +573,9 @@ async def render_followers_page(ctx: dict, actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Full page: followers list."""
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Followers "'
' (span :class "text-stone-400 font-normal" (raw! count-str)))'
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({total})"}, items=items_html,
content = render(
"federation-actor-list-page",
title="Followers", count_str=f"({total})", items_html=items_html,
)
return _social_page(ctx, actor, content_html=content,
title="Followers \u2014 Rose Ash")
@@ -737,61 +604,48 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
actor_url = getattr(remote_actor, "actor_url", "")
if icon_url:
avatar = sexp(
'(img :src src :alt "" :class "w-16 h-16 rounded-full")',
src=icon_url,
)
avatar = render("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
else:
initial = display_name[0].upper() if display_name else "?"
avatar = sexp(
'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl" (raw! i))',
i=initial,
avatar = render(
"federation-avatar-placeholder",
cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=initial,
)
summary_html = sexp(
'(div :class "text-sm text-stone-600 mt-2" (raw! s))',
s=summary,
) if summary else ""
summary_html = render("federation-profile-summary", summary=summary) if summary else ""
follow_html = ""
if actor:
if is_following:
follow_html = sexp(
'(div :class "flex-shrink-0"'
' (form :method "post" :action action'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "actor_url" :value aurl)'
' (button :type "submit" :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100" "Unfollow")))',
action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url,
follow_html = render(
"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_html = sexp(
'(div :class "flex-shrink-0"'
' (form :method "post" :action action'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "actor_url" :value aurl)'
' (button :type "submit" :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700" "Follow")))',
action=url_for("social.follow"), csrf=csrf, aurl=actor_url,
follow_html = render(
"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_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
content = sexp(
'(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
' (div :class "flex items-center gap-4"'
' (raw! avatar)'
' (div :class "flex-1"'
' (h1 :class "text-xl font-bold" (raw! dname))'
' (div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))'
' (raw! summary))'
' (raw! follow)))'
'(div :id "timeline" (raw! tl))',
avatar=avatar,
dname=str(escape(display_name)),
header_html = render(
"federation-actor-profile-header",
avatar_html=avatar,
display_name=str(escape(display_name)),
username=str(escape(remote_actor.preferred_username)),
domain=str(escape(remote_actor.domain)),
summary=summary_html, follow=follow_html,
tl=timeline_html,
summary_html=summary_html, follow_html=follow_html,
)
content = render(
"federation-actor-timeline-layout",
header_html=header_html, timeline_html=timeline_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -812,17 +666,14 @@ async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications:
notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")')
notif_html = render("federation-notifications-empty")
else:
notif_html = sexp(
'(div :class "space-y-2" (raw! items))',
items="".join(_notification_html(n) for n in notifications),
notif_html = render(
"federation-notifications-list",
items_html="".join(_notification_html(n) for n in notifications),
)
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)',
notifs=notif_html,
)
content = render("federation-notifications-page", notifs_html=notif_html)
return _social_page(ctx, actor, content_html=content,
title="Notifications \u2014 Rose Ash")
@@ -844,37 +695,13 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_html = sexp(
'(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
e=error,
) if error else ""
error_html = render("federation-error-banner", error=error) if error else ""
content = sexp(
'(div :class "py-8 max-w-md mx-auto"'
' (h1 :class "text-2xl font-bold mb-2" "Choose your username")'
' (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "'
' (strong "@username@" (raw! domain)))'
' (raw! err)'
' (form :method "post" :class "space-y-4"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div'
' (label :for "username" :class "block text-sm font-medium mb-1" "Username")'
' (div :class "flex items-center"'
' (span :class "text-stone-400 mr-1" "@")'
' (input :type "text" :name "username" :id "username" :value uname'
' :pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"'
' :required true :autocomplete "off"'
' :class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
' :hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"'
' :hx-include "[name=\'username\']"))'
' (div :id "username-status" :class "text-sm mt-1")'
' (p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))'
' (button :type "submit"'
' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
' "Claim username")))',
domain=str(escape(ap_domain)), err=error_html,
csrf=csrf, uname=str(escape(username)),
**{"check-url": check_url},
content = render(
"federation-choose-username",
domain=str(escape(ap_domain)), error_html=error_html,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return _social_page(ctx, actor, content_html=content,
@@ -892,9 +719,8 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
summary_html = sexp(
'(p :class "mt-2" (raw! s))',
s=str(escape(actor.summary)),
summary_html = render(
"federation-profile-summary-text", text=str(escape(actor.summary)),
) if actor.summary else ""
activities_html = ""
@@ -902,40 +728,26 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type_html = sexp(
'(span :class "text-sm text-stone-500" (raw! t))',
t=a.object_type,
obj_type_html = render(
"federation-activity-obj-type", obj_type=a.object_type,
) if a.object_type else ""
parts.append(sexp(
'(div :class "bg-white rounded-lg shadow p-4"'
' (div :class "flex justify-between items-start"'
' (span :class "font-medium" (raw! atype))'
' (span :class "text-sm text-stone-400" (raw! pub)))'
' (raw! otype))',
atype=a.activity_type, pub=published,
otype=obj_type_html,
parts.append(render(
"federation-activity-card",
activity_type=a.activity_type, published=published,
obj_type_html=obj_type_html,
))
activities_html = sexp(
'(div :class "space-y-4" (raw! items))',
items="".join(parts),
)
activities_html = render("federation-activities-list", items_html="".join(parts))
else:
activities_html = sexp('(p :class "text-stone-500" "No activities yet.")')
activities_html = render("federation-activities-empty")
content = sexp(
'(div :class "py-8"'
' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
' (h1 :class "text-2xl font-bold" (raw! dname))'
' (p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))'
' (raw! summary))'
' (h2 :class "text-xl font-bold mb-4" (raw! activities-heading))'
' (raw! activities))',
dname=str(escape(display_name)),
content = render(
"federation-profile-page",
display_name=str(escape(display_name)),
username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)),
summary=summary_html,
**{"activities-heading": f"Activities ({total})"},
activities=activities_html,
summary_html=summary_html,
activities_heading=f"Activities ({total})",
activities_html=activities_html,
)
return _social_page(ctx, actor, content_html=content,

View File

@@ -0,0 +1,121 @@
;; Social navigation, header, post cards, timeline, compose
;; --- Navigation ---
(defcomp ~federation-nav-choose-username (&key url)
(nav :class "flex gap-3 text-sm items-center"
(a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username")))
(defcomp ~federation-nav-link (&key href cls label)
(a :href href :class cls (raw! label)))
(defcomp ~federation-nav-notification-link (&key href cls count-url)
(a :href href :class cls "Notifications"
(span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"
:class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden")))
(defcomp ~federation-nav-bar (&key items-html)
(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items-html)))
(defcomp ~federation-social-header (&key nav-html)
(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"
(div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nav-html))))
(defcomp ~federation-header-child (&key inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! inner-html)))
;; --- Post card ---
(defcomp ~federation-boost-label (&key name)
(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name)))
(defcomp ~federation-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(defcomp ~federation-avatar-placeholder (&key cls initial)
(div :class cls (raw! initial)))
(defcomp ~federation-content-cw (&key summary content)
(details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (raw! summary))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content))))
(defcomp ~federation-content-plain (&key content)
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content)))
(defcomp ~federation-original-link (&key url)
(a :href url :target "_blank" :rel "noopener"
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
(defcomp ~federation-interactions-wrap (&key id buttons-html)
(div :id id (raw! buttons-html)))
(defcomp ~federation-post-card (&key boost-html avatar-html actor-name actor-username domain-html time-html content-html original-html interactions-html)
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
(raw! boost-html)
(div :class "flex items-start gap-3"
(raw! avatar-html)
(div :class "flex-1 min-w-0"
(div :class "flex items-baseline gap-2"
(span :class "font-semibold text-stone-900" (raw! actor-name))
(span :class "text-sm text-stone-500" "@" (raw! actor-username) (raw! domain-html))
(span :class "text-sm text-stone-400 ml-auto" (raw! time-html)))
(raw! content-html) (raw! original-html) (raw! interactions-html)))))
;; --- Interaction buttons ---
(defcomp ~federation-reply-link (&key url)
(a :href url :class "hover:text-stone-700" "Reply"))
(defcomp ~federation-like-form (&key action target oid ainbox csrf cls icon count)
(form :hx-post action :hx-target target :hx-swap "innerHTML"
(input :type "hidden" :name "object_id" :value oid)
(input :type "hidden" :name "author_inbox" :value ainbox)
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class cls (span (raw! icon)) " " (raw! count))))
(defcomp ~federation-boost-form (&key action target oid ainbox csrf cls count)
(form :hx-post action :hx-target target :hx-swap "innerHTML"
(input :type "hidden" :name "object_id" :value oid)
(input :type "hidden" :name "author_inbox" :value ainbox)
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class cls (span "\u21bb") " " (raw! count))))
(defcomp ~federation-interaction-buttons (&key like-html boost-html reply-html)
(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"
(raw! like-html) (raw! boost-html) (raw! reply-html)))
;; --- Timeline ---
(defcomp ~federation-scroll-sentinel (&key url)
(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML"))
(defcomp ~federation-compose-button (&key url)
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
(defcomp ~federation-timeline-page (&key label compose-html timeline-html)
(div :class "flex items-center justify-between mb-6"
(h1 :class "text-2xl font-bold" (raw! label) " Timeline")
(raw! compose-html))
(div :id "timeline" (raw! timeline-html)))
;; --- Compose ---
(defcomp ~federation-compose-reply (&key reply-to)
(input :type "hidden" :name "in_reply_to" :value reply-to)
(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! reply-to))))
(defcomp ~federation-compose-form (&key action csrf reply-html)
(h1 :class "text-2xl font-bold mb-6" "Compose")
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(raw! reply-html)
(textarea :name "content" :rows "6" :maxlength "5000" :required true
:class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"
:placeholder "What's on your mind?")
(div :class "flex items-center justify-between"
(select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"
(option :value "public" "Public")
(option :value "unlisted" "Unlisted")
(option :value "followers" "Followers only"))
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish"))))