""" 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 ( '' ) 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 = ['') return "".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 f'{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 = "♥" else: like_action = url_for("social.like") like_cls = "hover:text-red-500" like_icon = "♡" 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 = f'Reply' if reply_url else "" return ( f'
' f'
' f'' f'' f'' f'
' f'
' f'' f'' f'' f'
' f'{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 = f'
Boosted by {escape(boosted_by)}
' if boosted_by else "" if actor_icon: avatar = f'' else: initial = actor_name[0].upper() if actor_name else "?" avatar = f'
{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 = ( f'
CW: {escape(summary)}' f'
{content}
' ) else: content_html = f'
{content}
' original_html = "" if url and post_type == "remote": original_html = f'original' interactions_html = "" if actor: oid = getattr(item, "object_id", "") or "" safe_id = oid.replace("/", "_").replace(":", "_") interactions_html = f'
{_interaction_buttons_html(item, actor)}
' return ( f'
' f'{boost_html}' f'
{avatar}' f'
' f'
' f'{escape(actor_name)}' f'@{escape(actor_username)}{domain_html}' f'{time_html}
' f'{content_html}{original_html}{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(f'
') 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 = f'' else: initial = (display_name or username)[0].upper() if (display_name or username) else "?" avatar = f'
{initial}
' # Name link if list_type == "following" and aid: name_html = f'{escape(display_name)}' elif list_type == "search" and aid: name_html = f'{escape(display_name)}' else: name_html = f'{escape(display_name)}' summary_html = f'
{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 = ( f'
' f'' f'' f'
' ) else: label = "Follow Back" if list_type == "followers" else "Follow" button_html = ( f'
' f'' f'' f'
' ) return ( f'
' f'{avatar}
{name_html}' f'
@{escape(username)}@{escape(domain)}
' f'{summary_html}
{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(f'
') 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(f'
') 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 = f'' else: initial = from_name[0].upper() if from_name else "?" avatar = f'
{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 = f'
{escape(preview)}
' if preview else "" time_html = created.strftime("%b %d, %H:%M") if created else "" return ( f'
' f'
{avatar}
' f'
{escape(from_name)}' f' @{escape(from_username)}{domain_html}' f' {action}
' f'{preview_html}
{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 = f'
{error}
' if error else "" content = ( f'

Sign in

{error_html}' f'
' f'' f'
' f'
' f'
' ) hdr = root_header_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=content, meta_html="Login \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 = f'Compose' timeline_html = _timeline_items_html(items, timeline_type, actor) content = ( f'
' f'

{label} Timeline

{compose_html}
' f'
{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 = ( f'' f'
Replying to {escape(reply_to)}
' ) content = ( f'

Compose

' f'
' f'{reply_html}' f'' f'
' f'' f'
' ) 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 = f'

{total} result{s} for {escape(query)}

' elif query: info_html = f'

No results found for {escape(query)}

' content = ( f'

Search

' f'
' f'
' f'
' f'{info_html}
{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 = ( f'

Following ({total})

' f'
{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 = ( f'

Followers ({total})

' f'
{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 = f'' else: initial = display_name[0].upper() if display_name else "?" avatar = f'
{initial}
' summary_html = f'
{summary}
' if summary else "" follow_html = "" if actor: if is_following: follow_html = ( f'
' f'' f'' f'
' ) else: follow_html = ( f'
' f'' f'' f'
' ) timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id) content = ( f'
' f'
{avatar}' f'

{escape(display_name)}

' f'
@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}
' f'{summary_html}
{follow_html}
' f'
{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 = '

No notifications yet.

' else: notif_html = '
' + "".join(_notification_html(n) for n in notifications) + '
' content = f'

Notifications

{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 = f'
{error}
' if error else "" content = ( f'
' f'

Choose your username

' f'

This will be your identity on the fediverse: ' f'@username@{escape(ap_domain)}

' f'{error_html}' f'
' f'' f'
' f'
@' f'' f'
' f'

3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.

' f'
' ) 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 = f'

{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 = f'{a.object_type}' if a.object_type else "" parts.append( f'
' f'{a.activity_type}' f'{published}
{obj_type}
' ) activities_html = '
' + "".join(parts) + '
' else: activities_html = '

No activities yet.

' content = ( f'
' f'

{escape(display_name)}

' f'

@{escape(actor.preferred_username)}@{escape(ap_domain)}

' f'{summary_html}
' f'

Activities ({total})

{activities_html}
' ) return _social_page(ctx, actor, content_html=content, title=f"@{actor.preferred_username} \u2014 Rose Ash")