The sexp parser doesn't handle "(" and ")" as string literals
inside expressions. Use raw! with pre-formatted strings instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
970 lines
39 KiB
Python
970 lines
39 KiB
Python
"""
|
|
Federation service s-expression page components.
|
|
|
|
Renders social timeline, compose, search, following/followers, notifications,
|
|
actor profiles, login, and username selection pages.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from markupsafe import escape
|
|
|
|
from shared.sexp.jinja_bridge import sexp
|
|
from shared.sexp.helpers import root_header_html, full_page
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Social header nav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _social_nav_html(actor: Any) -> str:
|
|
"""Build the social header nav bar content."""
|
|
from quart import url_for, request
|
|
|
|
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,
|
|
)
|
|
|
|
links = [
|
|
("social.home_timeline", "Timeline"),
|
|
("social.public_timeline", "Public"),
|
|
("social.compose_form", "Compose"),
|
|
("social.following_list", "Following"),
|
|
("social.followers_list", "Followers"),
|
|
("social.search", "Search"),
|
|
]
|
|
|
|
parts = []
|
|
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))',
|
|
href=href,
|
|
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
|
|
label=label,
|
|
))
|
|
|
|
# Notifications with live badge
|
|
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},
|
|
))
|
|
|
|
# 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}",
|
|
))
|
|
|
|
return sexp(
|
|
'(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))',
|
|
items="".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,
|
|
)
|
|
|
|
|
|
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),
|
|
)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
|
|
meta_html=meta_html or sexp('(title (raw! t))', t=escape(title)))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Post card
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _interaction_buttons_html(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.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 ""
|
|
|
|
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)))',
|
|
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)))',
|
|
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,
|
|
)
|
|
|
|
|
|
def _post_card_html(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_html = sexp(
|
|
'(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))',
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
else:
|
|
content_html = sexp(
|
|
'(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))',
|
|
c=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,
|
|
)
|
|
|
|
interactions_html = ""
|
|
if actor:
|
|
oid = getattr(item, "object_id", "") or ""
|
|
safe_id = oid.replace("/", "_").replace(":", "_")
|
|
interactions_html = sexp(
|
|
'(div :id id (raw! buttons))',
|
|
id=f"interactions-{safe_id}",
|
|
buttons=_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,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Timeline items (pagination fragment)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _timeline_items_html(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_html(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(sexp(
|
|
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
|
|
url=next_url,
|
|
))
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search results (pagination fragment)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _actor_card_html(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(":", "_")
|
|
|
|
if icon_url:
|
|
avatar = sexp(
|
|
'(img :src src :alt "" :class "w-12 h-12 rounded-full")',
|
|
src=icon_url,
|
|
)
|
|
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,
|
|
)
|
|
|
|
# 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))',
|
|
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))',
|
|
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 ""
|
|
|
|
# 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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
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))',
|
|
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},
|
|
username=str(escape(username)), domain=str(escape(domain)),
|
|
summary=summary_html, button=button_html,
|
|
)
|
|
|
|
|
|
def _search_results_html(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_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,
|
|
))
|
|
return "".join(parts)
|
|
|
|
|
|
def _actor_list_items_html(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_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,
|
|
))
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notification card
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _notification_html(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 ""
|
|
|
|
if from_icon:
|
|
avatar = sexp(
|
|
'(img :src src :alt "" :class "w-8 h-8 rounded-full")',
|
|
src=from_icon,
|
|
)
|
|
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,
|
|
)
|
|
|
|
domain_html = 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_html = sexp(
|
|
'(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))',
|
|
p=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)))))',
|
|
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,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Home page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_federation_home(ctx: dict) -> str:
|
|
"""Full page: federation home (minimal)."""
|
|
hdr = root_header_html(ctx)
|
|
return full_page(ctx, header_rows_html=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_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 ""
|
|
|
|
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,
|
|
email=str(escape(email)),
|
|
)
|
|
|
|
hdr = root_header_html(ctx)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
|
meta_html='<title>Login \u2014 Rose Ash</title>')
|
|
|
|
|
|
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_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)),
|
|
) 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,
|
|
)
|
|
|
|
hdr = root_header_html(ctx)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
|
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Timeline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
|
|
actor: Any) -> str:
|
|
"""Full page: timeline (home or public)."""
|
|
from quart import url_for
|
|
|
|
label = "Home" if timeline_type == "home" else "Public"
|
|
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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title=f"{label} Timeline \u2014 Rose Ash")
|
|
|
|
|
|
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_html(items, timeline_type, actor, actor_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Compose
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
|
|
"""Full page: compose form."""
|
|
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_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)),
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title="Compose \u2014 Rose Ash")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_search_page(ctx: dict, query: str, actors: list, total: int,
|
|
page: int, followed_urls: set, actor: Any) -> str:
|
|
"""Full page: search."""
|
|
from quart import url_for
|
|
|
|
search_url = url_for("social.search")
|
|
search_page_url = url_for("social.search_page")
|
|
|
|
results_html = _search_results_html(actors, query, page, followed_urls, actor)
|
|
|
|
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>",
|
|
)
|
|
elif query:
|
|
info_html = sexp(
|
|
'(p :class "text-stone-500 mb-4" (raw! t))',
|
|
t=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},
|
|
query=str(escape(query)),
|
|
info=info_html, results=results_html,
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title="Search \u2014 Rose Ash")
|
|
|
|
|
|
async def render_search_results(actors: list, query: str, page: int,
|
|
followed_urls: set, actor: Any) -> str:
|
|
"""Pagination fragment: search results."""
|
|
return _search_results_html(actors, query, page, followed_urls, actor)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Following / Followers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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,
|
|
)
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title="Following \u2014 Rose Ash")
|
|
|
|
|
|
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
|
"""Pagination fragment: following items."""
|
|
return _actor_list_items_html(actors, page, "following", set(), actor)
|
|
|
|
|
|
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,
|
|
)
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title="Followers \u2014 Rose Ash")
|
|
|
|
|
|
async def render_followers_items(actors: list, page: int,
|
|
followed_urls: set, actor: Any) -> str:
|
|
"""Pagination fragment: followers items."""
|
|
return _actor_list_items_html(actors, page, "followers", followed_urls, actor)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Actor timeline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
|
|
is_following: bool, actor: Any) -> str:
|
|
"""Full page: remote actor timeline."""
|
|
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", "")
|
|
|
|
if icon_url:
|
|
avatar = sexp(
|
|
'(img :src src :alt "" :class "w-16 h-16 rounded-full")',
|
|
src=icon_url,
|
|
)
|
|
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,
|
|
)
|
|
|
|
summary_html = sexp(
|
|
'(div :class "text-sm text-stone-600 mt-2" (raw! s))',
|
|
s=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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
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)),
|
|
username=str(escape(remote_actor.preferred_username)),
|
|
domain=str(escape(remote_actor.domain)),
|
|
summary=summary_html, follow=follow_html,
|
|
tl=timeline_html,
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title=f"{display_name} \u2014 Rose Ash")
|
|
|
|
|
|
async def render_actor_timeline_items(items: list, actor_id: int,
|
|
actor: Any) -> str:
|
|
"""Pagination fragment: actor timeline items."""
|
|
return _timeline_items_html(items, "actor", actor, actor_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: Notifications
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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.")')
|
|
else:
|
|
notif_html = sexp(
|
|
'(div :class "space-y-2" (raw! items))',
|
|
items="".join(_notification_html(n) for n in notifications),
|
|
)
|
|
|
|
content = sexp(
|
|
'(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)',
|
|
notifs=notif_html,
|
|
)
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title="Notifications \u2014 Rose Ash")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
csrf = generate_csrf_token()
|
|
error = ctx.get("error", "")
|
|
username = ctx.get("username", "")
|
|
ap_domain = config().get("ap_domain", "rose-ash.com")
|
|
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 ""
|
|
|
|
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},
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=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")
|
|
display_name = actor.display_name or actor.preferred_username
|
|
summary_html = sexp(
|
|
'(p :class "mt-2" (raw! s))',
|
|
s=str(escape(actor.summary)),
|
|
) if actor.summary else ""
|
|
|
|
activities_html = ""
|
|
if activities:
|
|
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,
|
|
) 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,
|
|
))
|
|
activities_html = sexp(
|
|
'(div :class "space-y-4" (raw! items))',
|
|
items="".join(parts),
|
|
)
|
|
else:
|
|
activities_html = sexp('(p :class "text-stone-500" "No activities yet.")')
|
|
|
|
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)),
|
|
username=str(escape(actor.preferred_username)),
|
|
domain=str(escape(ap_domain)),
|
|
summary=summary_html,
|
|
**{"activities-heading": f"Activities ({total})"},
|
|
activities=activities_html,
|
|
)
|
|
|
|
return _social_page(ctx, actor, content_html=content,
|
|
title=f"@{actor.preferred_username} \u2014 Rose Ash")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: POST handler fragment renderers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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_html(item, actor)
|
|
|
|
|
|
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_html(actor_dto, actor, followed_urls, list_type=list_type)
|