""" 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='Login \u2014 Rose Ash') async def render_check_email_page(ctx: dict) -> str: """Full page: check email after magic link sent.""" email = ctx.get("email", "") email_error = ctx.get("email_error") error_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='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- # 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 {escape(query)}", ) elif query: info_html = sexp( '(p :class "text-stone-500 mb-4" (raw! t))', t=f"No results found for {escape(query)}", ) 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)