""" 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='