"""
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 (
''
f'Choose username '
' '
)
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(f'{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(
f'Notifications'
f' '
)
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(f'@{actor.preferred_username} ')
parts.append(' ')
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'{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'
'
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_html = (
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''
)
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''
)
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'Search
'
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'Unfollow '
)
else:
follow_html = (
f'
'
f' '
f' '
f'Follow '
)
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'Username '
f'
@ '
f' '
f'
'
f'
3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.
'
f'Claim username '
)
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")