Add full fediverse social service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Social blueprint with timeline, compose, search, follow/unfollow, like/boost interactions, and notifications. Inbox handler extended for Create/Update/Delete/Accept/Like/Announce with notification creation. HTMX-powered infinite scroll and interaction buttons. Nav updated with Timeline, Public, Search, and Notifications links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
app.py
2
app.py
@@ -13,6 +13,7 @@ from bp import (
|
||||
register_actors_bp,
|
||||
register_identity_bp,
|
||||
register_auth_bp,
|
||||
register_social_bp,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +54,7 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_actors_bp())
|
||||
app.register_blueprint(register_identity_bp())
|
||||
app.register_blueprint(register_auth_bp())
|
||||
app.register_blueprint(register_social_bp())
|
||||
|
||||
# --- home page ---
|
||||
@app.get("/")
|
||||
|
||||
@@ -2,3 +2,4 @@ from .wellknown.routes import register as register_wellknown_bp
|
||||
from .actors.routes import register as register_actors_bp
|
||||
from .identity.routes import register as register_identity_bp
|
||||
from .auth.routes import register as register_auth_bp
|
||||
from .social.routes import register as register_social_bp
|
||||
|
||||
@@ -301,8 +301,18 @@ def register(url_prefix="/users"):
|
||||
await _handle_follow(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Undo":
|
||||
await _handle_undo(actor_row, body, from_actor_url)
|
||||
elif activity_type in ("Like", "Announce"):
|
||||
log.info("Received %s from %s (noted)", activity_type, from_actor_url)
|
||||
elif activity_type == "Accept":
|
||||
await _handle_accept(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Create":
|
||||
await _handle_create(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Update":
|
||||
await _handle_update(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Delete":
|
||||
await _handle_delete(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Like":
|
||||
await _handle_like(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Announce":
|
||||
await _handle_announce(actor_row, body, from_actor_url)
|
||||
|
||||
# Mark as processed
|
||||
item.state = "processed"
|
||||
@@ -351,6 +361,29 @@ def register(url_prefix="/users"):
|
||||
follower_acct, actor_row.preferred_username,
|
||||
)
|
||||
|
||||
# Notification
|
||||
from shared.models.federation import APNotification, RemoteActor
|
||||
ra = (
|
||||
await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not ra:
|
||||
# Store this remote actor
|
||||
ra_dto = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if ra_dto:
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if ra:
|
||||
notif = APNotification(
|
||||
actor_profile_id=actor_row.id,
|
||||
notification_type="follow",
|
||||
from_remote_actor_id=ra.id,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
# Send Accept
|
||||
await _send_accept(actor_row, body, follower_inbox)
|
||||
|
||||
@@ -388,6 +421,246 @@ def register(url_prefix="/users"):
|
||||
else:
|
||||
log.debug("Undo for %s — not handled", inner_type)
|
||||
|
||||
async def _handle_accept(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process Accept activity — update outbound follow state."""
|
||||
inner = body.get("object")
|
||||
if not inner:
|
||||
return
|
||||
|
||||
inner_type = inner.get("type") if isinstance(inner, dict) else None
|
||||
if inner_type == "Follow":
|
||||
await services.federation.accept_follow_response(
|
||||
g.s, actor_row.preferred_username, from_actor_url,
|
||||
)
|
||||
log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username)
|
||||
|
||||
async def _handle_create(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process Create(Note/Article) — ingest remote post."""
|
||||
obj = body.get("object")
|
||||
if not obj or not isinstance(obj, dict):
|
||||
return
|
||||
|
||||
obj_type = obj.get("type", "")
|
||||
if obj_type not in ("Note", "Article"):
|
||||
log.debug("Create with type %s — skipping", obj_type)
|
||||
return
|
||||
|
||||
# Get or create remote actor
|
||||
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if not remote:
|
||||
log.warning("Could not resolve remote actor for Create: %s", from_actor_url)
|
||||
return
|
||||
|
||||
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
|
||||
log.info("Ingested %s from %s", obj_type, from_actor_url)
|
||||
|
||||
# Create notification if mentions a local actor
|
||||
from shared.models.federation import APNotification, APRemotePost, RemoteActor
|
||||
tags = obj.get("tag", [])
|
||||
if isinstance(tags, list):
|
||||
domain = _domain()
|
||||
for tag in tags:
|
||||
if not isinstance(tag, dict):
|
||||
continue
|
||||
if tag.get("type") != "Mention":
|
||||
continue
|
||||
href = tag.get("href", "")
|
||||
if f"https://{domain}/users/" in href:
|
||||
mentioned_username = href.rsplit("/", 1)[-1]
|
||||
mentioned = await services.federation.get_actor_by_username(
|
||||
g.s, mentioned_username,
|
||||
)
|
||||
if mentioned:
|
||||
# Find the remote post we just created
|
||||
rp = (await g.s.execute(
|
||||
select(APRemotePost).where(
|
||||
APRemotePost.object_id == obj.get("id")
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
notif = APNotification(
|
||||
actor_profile_id=mentioned.id,
|
||||
notification_type="mention",
|
||||
from_remote_actor_id=ra.id if ra else None,
|
||||
target_remote_post_id=rp.id if rp else None,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
# Also check if it's a reply to one of our posts
|
||||
in_reply_to = obj.get("inReplyTo")
|
||||
if in_reply_to and f"https://{domain}/users/" in str(in_reply_to):
|
||||
# It's a reply to one of our local posts
|
||||
from shared.models.federation import APActivity
|
||||
local_activity = (await g.s.execute(
|
||||
select(APActivity).where(
|
||||
APActivity.activity_id == in_reply_to,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if local_activity:
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
rp = (await g.s.execute(
|
||||
select(APRemotePost).where(
|
||||
APRemotePost.object_id == obj.get("id")
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
notif = APNotification(
|
||||
actor_profile_id=local_activity.actor_profile_id,
|
||||
notification_type="reply",
|
||||
from_remote_actor_id=ra.id if ra else None,
|
||||
target_remote_post_id=rp.id if rp else None,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
async def _handle_update(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process Update — re-ingest remote post."""
|
||||
obj = body.get("object")
|
||||
if not obj or not isinstance(obj, dict):
|
||||
return
|
||||
obj_type = obj.get("type", "")
|
||||
if obj_type in ("Note", "Article"):
|
||||
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if remote:
|
||||
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
|
||||
log.info("Updated %s from %s", obj_type, from_actor_url)
|
||||
|
||||
async def _handle_delete(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process Delete — remove remote post."""
|
||||
obj = body.get("object")
|
||||
if isinstance(obj, str):
|
||||
object_id = obj
|
||||
elif isinstance(obj, dict):
|
||||
object_id = obj.get("id", "")
|
||||
else:
|
||||
return
|
||||
if object_id:
|
||||
await services.federation.delete_remote_post(g.s, object_id)
|
||||
log.info("Deleted remote post %s from %s", object_id, from_actor_url)
|
||||
|
||||
async def _handle_like(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process incoming Like — record interaction + notify."""
|
||||
from shared.models.federation import APInteraction, APNotification, RemoteActor
|
||||
|
||||
object_id = body.get("object", "")
|
||||
if isinstance(object_id, dict):
|
||||
object_id = object_id.get("id", "")
|
||||
if not object_id:
|
||||
return
|
||||
|
||||
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if not remote:
|
||||
return
|
||||
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Find the local activity this Like targets
|
||||
from shared.models.federation import APActivity
|
||||
target = (await g.s.execute(
|
||||
select(APActivity).where(APActivity.activity_id == object_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if not target:
|
||||
# Try matching object_data.id
|
||||
log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id)
|
||||
return
|
||||
|
||||
# Record interaction
|
||||
interaction = APInteraction(
|
||||
remote_actor_id=ra.id if ra else None,
|
||||
post_type="local",
|
||||
post_id=target.id,
|
||||
interaction_type="like",
|
||||
activity_id=body.get("id"),
|
||||
)
|
||||
g.s.add(interaction)
|
||||
|
||||
# Notification
|
||||
notif = APNotification(
|
||||
actor_profile_id=target.actor_profile_id,
|
||||
notification_type="like",
|
||||
from_remote_actor_id=ra.id if ra else None,
|
||||
target_activity_id=target.id,
|
||||
)
|
||||
g.s.add(notif)
|
||||
log.info("Like from %s on activity %s", from_actor_url, object_id)
|
||||
|
||||
async def _handle_announce(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process incoming Announce (boost) — record interaction + notify."""
|
||||
from shared.models.federation import APInteraction, APNotification, RemoteActor
|
||||
|
||||
object_id = body.get("object", "")
|
||||
if isinstance(object_id, dict):
|
||||
object_id = object_id.get("id", "")
|
||||
if not object_id:
|
||||
return
|
||||
|
||||
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if not remote:
|
||||
return
|
||||
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
from shared.models.federation import APActivity
|
||||
target = (await g.s.execute(
|
||||
select(APActivity).where(APActivity.activity_id == object_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if not target:
|
||||
log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id)
|
||||
return
|
||||
|
||||
interaction = APInteraction(
|
||||
remote_actor_id=ra.id if ra else None,
|
||||
post_type="local",
|
||||
post_id=target.id,
|
||||
interaction_type="boost",
|
||||
activity_id=body.get("id"),
|
||||
)
|
||||
g.s.add(interaction)
|
||||
|
||||
notif = APNotification(
|
||||
actor_profile_id=target.actor_profile_id,
|
||||
notification_type="boost",
|
||||
from_remote_actor_id=ra.id if ra else None,
|
||||
target_activity_id=target.id,
|
||||
)
|
||||
g.s.add(notif)
|
||||
log.info("Announce from %s on activity %s", from_actor_url, object_id)
|
||||
|
||||
@bp.get("/<username>/followers")
|
||||
async def followers(username: str):
|
||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
||||
@@ -431,6 +704,7 @@ def register(url_prefix="/users"):
|
||||
|
||||
domain = _domain()
|
||||
collection_id = f"https://{domain}/users/{username}/following"
|
||||
following_list, total = await services.federation.get_following(g.s, username)
|
||||
page_param = request.args.get("page")
|
||||
|
||||
if not page_param:
|
||||
@@ -439,7 +713,7 @@ def register(url_prefix="/users"):
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": 0,
|
||||
"totalItems": total,
|
||||
"first": f"{collection_id}?page=1",
|
||||
}),
|
||||
content_type=AP_CONTENT_TYPE,
|
||||
@@ -451,8 +725,8 @@ def register(url_prefix="/users"):
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": f"{collection_id}?page=1",
|
||||
"partOf": collection_id,
|
||||
"totalItems": 0,
|
||||
"orderedItems": [],
|
||||
"totalItems": total,
|
||||
"orderedItems": [f.actor_url for f in following_list],
|
||||
}),
|
||||
content_type=AP_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
0
bp/social/__init__.py
Normal file
0
bp/social/__init__.py
Normal file
302
bp/social/routes.py
Normal file
302
bp/social/routes.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Social fediverse routes: timeline, compose, search, follow, interactions, notifications."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
|
||||
|
||||
from shared.services.registry import services
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _require_actor():
|
||||
"""Return actor context or abort 403."""
|
||||
actor = g.get("ctx", {}).get("actor") if hasattr(g, "ctx") else None
|
||||
if not actor:
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
if not actor:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
|
||||
def register(url_prefix="/social"):
|
||||
bp = Blueprint("social", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.before_request
|
||||
async def load_actor():
|
||||
"""Load actor profile for authenticated users."""
|
||||
if g.get("user"):
|
||||
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
||||
g._social_actor = actor
|
||||
|
||||
# -- Timeline -------------------------------------------------------------
|
||||
|
||||
@bp.get("/")
|
||||
async def home_timeline():
|
||||
if not g.get("user"):
|
||||
return redirect(url_for("auth.login_form"))
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||
return await render_template(
|
||||
"federation/timeline.html",
|
||||
items=items,
|
||||
timeline_type="home",
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
@bp.get("/timeline")
|
||||
async def home_timeline_page():
|
||||
actor = _require_actor()
|
||||
before_str = request.args.get("before")
|
||||
before = None
|
||||
if before_str:
|
||||
try:
|
||||
before = datetime.fromisoformat(before_str)
|
||||
except ValueError:
|
||||
pass
|
||||
items = await services.federation.get_home_timeline(
|
||||
g.s, actor.id, before=before,
|
||||
)
|
||||
return await render_template(
|
||||
"federation/_timeline_items.html",
|
||||
items=items,
|
||||
timeline_type="home",
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
@bp.get("/public")
|
||||
async def public_timeline():
|
||||
items = await services.federation.get_public_timeline(g.s)
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
return await render_template(
|
||||
"federation/timeline.html",
|
||||
items=items,
|
||||
timeline_type="public",
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
@bp.get("/public/timeline")
|
||||
async def public_timeline_page():
|
||||
before_str = request.args.get("before")
|
||||
before = None
|
||||
if before_str:
|
||||
try:
|
||||
before = datetime.fromisoformat(before_str)
|
||||
except ValueError:
|
||||
pass
|
||||
items = await services.federation.get_public_timeline(g.s, before=before)
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
return await render_template(
|
||||
"federation/_timeline_items.html",
|
||||
items=items,
|
||||
timeline_type="public",
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
# -- Compose --------------------------------------------------------------
|
||||
|
||||
@bp.get("/compose")
|
||||
async def compose_form():
|
||||
actor = _require_actor()
|
||||
reply_to = request.args.get("reply_to")
|
||||
return await render_template(
|
||||
"federation/compose.html",
|
||||
actor=actor,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
|
||||
@bp.post("/compose")
|
||||
async def compose_submit():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
content = form.get("content", "").strip()
|
||||
if not content:
|
||||
return redirect(url_for("social.compose_form"))
|
||||
|
||||
visibility = form.get("visibility", "public")
|
||||
in_reply_to = form.get("in_reply_to") or None
|
||||
|
||||
await services.federation.create_local_post(
|
||||
g.s, actor.id,
|
||||
content=content,
|
||||
visibility=visibility,
|
||||
in_reply_to=in_reply_to,
|
||||
)
|
||||
return redirect(url_for("social.home_timeline"))
|
||||
|
||||
@bp.post("/delete/<int:post_id>")
|
||||
async def delete_post(post_id: int):
|
||||
actor = _require_actor()
|
||||
await services.federation.delete_local_post(g.s, actor.id, post_id)
|
||||
return redirect(url_for("social.home_timeline"))
|
||||
|
||||
# -- Search + Follow ------------------------------------------------------
|
||||
|
||||
@bp.get("/search")
|
||||
async def search():
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
query = request.args.get("q", "").strip()
|
||||
result = None
|
||||
if query:
|
||||
result = await services.federation.search_remote_actor(g.s, query)
|
||||
return await render_template(
|
||||
"federation/search.html",
|
||||
query=query,
|
||||
result=result,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
@bp.post("/follow")
|
||||
async def follow():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
remote_actor_url = form.get("actor_url", "")
|
||||
if remote_actor_url:
|
||||
await services.federation.send_follow(
|
||||
g.s, actor.preferred_username, remote_actor_url,
|
||||
)
|
||||
return redirect(url_for("social.search"))
|
||||
|
||||
@bp.post("/unfollow")
|
||||
async def unfollow():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
remote_actor_url = form.get("actor_url", "")
|
||||
if remote_actor_url:
|
||||
await services.federation.unfollow(
|
||||
g.s, actor.preferred_username, remote_actor_url,
|
||||
)
|
||||
return redirect(url_for("social.search"))
|
||||
|
||||
# -- Interactions ---------------------------------------------------------
|
||||
|
||||
@bp.post("/like")
|
||||
async def like():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
object_id = form.get("object_id", "")
|
||||
author_inbox = form.get("author_inbox", "")
|
||||
await services.federation.like_post(g.s, actor.id, object_id, author_inbox)
|
||||
# Return updated buttons for HTMX
|
||||
return await _interaction_buttons_response(actor, object_id, author_inbox)
|
||||
|
||||
@bp.post("/unlike")
|
||||
async def unlike():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
object_id = form.get("object_id", "")
|
||||
author_inbox = form.get("author_inbox", "")
|
||||
await services.federation.unlike_post(g.s, actor.id, object_id, author_inbox)
|
||||
return await _interaction_buttons_response(actor, object_id, author_inbox)
|
||||
|
||||
@bp.post("/boost")
|
||||
async def boost():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
object_id = form.get("object_id", "")
|
||||
author_inbox = form.get("author_inbox", "")
|
||||
await services.federation.boost_post(g.s, actor.id, object_id, author_inbox)
|
||||
return await _interaction_buttons_response(actor, object_id, author_inbox)
|
||||
|
||||
@bp.post("/unboost")
|
||||
async def unboost():
|
||||
actor = _require_actor()
|
||||
form = await request.form
|
||||
object_id = form.get("object_id", "")
|
||||
author_inbox = form.get("author_inbox", "")
|
||||
await services.federation.unboost_post(g.s, actor.id, object_id, author_inbox)
|
||||
return await _interaction_buttons_response(actor, object_id, author_inbox)
|
||||
|
||||
async def _interaction_buttons_response(actor, object_id, author_inbox):
|
||||
"""Re-render interaction buttons after a like/boost action."""
|
||||
from shared.models.federation import APInteraction, APRemotePost, APActivity
|
||||
from sqlalchemy import select
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
|
||||
svc = services.federation
|
||||
post_type, post_id = await svc._resolve_post(g.s, object_id)
|
||||
|
||||
like_count = 0
|
||||
boost_count = 0
|
||||
liked_by_me = False
|
||||
boosted_by_me = False
|
||||
|
||||
if post_type:
|
||||
from sqlalchemy import func as sa_func
|
||||
like_count = (await g.s.execute(
|
||||
select(sa_func.count(APInteraction.id)).where(
|
||||
APInteraction.post_type == post_type,
|
||||
APInteraction.post_id == post_id,
|
||||
APInteraction.interaction_type == "like",
|
||||
)
|
||||
)).scalar() or 0
|
||||
boost_count = (await g.s.execute(
|
||||
select(sa_func.count(APInteraction.id)).where(
|
||||
APInteraction.post_type == post_type,
|
||||
APInteraction.post_id == post_id,
|
||||
APInteraction.interaction_type == "boost",
|
||||
)
|
||||
)).scalar() or 0
|
||||
liked_by_me = bool((await g.s.execute(
|
||||
select(APInteraction.id).where(
|
||||
APInteraction.actor_profile_id == actor.id,
|
||||
APInteraction.post_type == post_type,
|
||||
APInteraction.post_id == post_id,
|
||||
APInteraction.interaction_type == "like",
|
||||
).limit(1)
|
||||
)).scalar())
|
||||
boosted_by_me = bool((await g.s.execute(
|
||||
select(APInteraction.id).where(
|
||||
APInteraction.actor_profile_id == actor.id,
|
||||
APInteraction.post_type == post_type,
|
||||
APInteraction.post_id == post_id,
|
||||
APInteraction.interaction_type == "boost",
|
||||
).limit(1)
|
||||
)).scalar())
|
||||
|
||||
return await render_template(
|
||||
"federation/_interaction_buttons.html",
|
||||
item_object_id=object_id,
|
||||
item_author_inbox=author_inbox,
|
||||
like_count=like_count,
|
||||
boost_count=boost_count,
|
||||
liked_by_me=liked_by_me,
|
||||
boosted_by_me=boosted_by_me,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
# -- Notifications --------------------------------------------------------
|
||||
|
||||
@bp.get("/notifications")
|
||||
async def notifications():
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_notifications(g.s, actor.id)
|
||||
await services.federation.mark_notifications_read(g.s, actor.id)
|
||||
return await render_template(
|
||||
"federation/notifications.html",
|
||||
notifications=items,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
@bp.get("/notifications/count")
|
||||
async def notification_count():
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
if not actor:
|
||||
return Response("0", content_type="text/plain")
|
||||
count = await services.federation.unread_notification_count(g.s, actor.id)
|
||||
if count > 0:
|
||||
return Response(
|
||||
f'<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5">{count}</span>',
|
||||
content_type="text/html",
|
||||
)
|
||||
return Response("", content_type="text/html")
|
||||
|
||||
@bp.post("/notifications/read")
|
||||
async def mark_read():
|
||||
actor = _require_actor()
|
||||
await services.federation.mark_notifications_read(g.s, actor.id)
|
||||
return redirect(url_for("social.notifications"))
|
||||
|
||||
return bp
|
||||
2
shared
2
shared
Submodule shared updated: 9a8b556c13...bccfff0c69
61
templates/federation/_interaction_buttons.html
Normal file
61
templates/federation/_interaction_buttons.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% set oid = item.object_id if item is defined and item.object_id is defined else item_object_id | default('') %}
|
||||
{% set ainbox = item.author_inbox if item is defined and item.author_inbox is defined else item_author_inbox | default('') %}
|
||||
{% set lcount = item.like_count if item is defined and item.like_count is defined else like_count | default(0) %}
|
||||
{% set bcount = item.boost_count if item is defined and item.boost_count is defined else boost_count | default(0) %}
|
||||
{% set liked = item.liked_by_me if item is defined and item.liked_by_me is defined else liked_by_me | default(false) %}
|
||||
{% set boosted = item.boosted_by_me if item is defined and item.boosted_by_me is defined else boosted_by_me | default(false) %}
|
||||
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">
|
||||
{% if liked %}
|
||||
<form hx-post="{{ url_for('social.unlike') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
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_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 text-red-500 hover:text-red-600">
|
||||
<span>♥</span> {{ lcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.like') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
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_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 hover:text-red-500">
|
||||
<span>♡</span> {{ lcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if boosted %}
|
||||
<form hx-post="{{ url_for('social.unboost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
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_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 text-green-600 hover:text-green-700">
|
||||
<span>↻</span> {{ bcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.boost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
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_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 hover:text-green-600">
|
||||
<span>↻</span> {{ bcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if oid %}
|
||||
<a href="{{ url_for('social.compose_form', reply_to=oid) }}"
|
||||
class="hover:text-stone-700">Reply</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
42
templates/federation/_notification.html
Normal file
42
templates/federation/_notification.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 {{ 'border-l-4 border-l-stone-400' if not notif.read }}">
|
||||
<div class="flex items-start gap-3">
|
||||
{% if notif.from_actor_icon %}
|
||||
<img src="{{ notif.from_actor_icon }}" alt="" class="w-8 h-8 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">
|
||||
{{ notif.from_actor_name[0] | upper if notif.from_actor_name else '?' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="text-sm">
|
||||
<span class="font-semibold">{{ notif.from_actor_name }}</span>
|
||||
<span class="text-stone-500">
|
||||
@{{ notif.from_actor_username }}{% if notif.from_actor_domain %}@{{ notif.from_actor_domain }}{% endif %}
|
||||
</span>
|
||||
|
||||
{% if notif.notification_type == "follow" %}
|
||||
<span class="text-stone-600">followed you</span>
|
||||
{% elif notif.notification_type == "like" %}
|
||||
<span class="text-stone-600">liked your post</span>
|
||||
{% elif notif.notification_type == "boost" %}
|
||||
<span class="text-stone-600">boosted your post</span>
|
||||
{% elif notif.notification_type == "mention" %}
|
||||
<span class="text-stone-600">mentioned you</span>
|
||||
{% elif notif.notification_type == "reply" %}
|
||||
<span class="text-stone-600">replied to your post</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if notif.target_content_preview %}
|
||||
<div class="text-sm text-stone-500 mt-1 truncate">
|
||||
{{ notif.target_content_preview }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-xs text-stone-400 mt-1">
|
||||
{{ notif.created_at.strftime('%b %d, %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
52
templates/federation/_post_card.html
Normal file
52
templates/federation/_post_card.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
|
||||
{% if item.boosted_by %}
|
||||
<div class="text-sm text-stone-500 mb-2">
|
||||
Boosted by {{ item.boosted_by }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
{% if item.actor_icon %}
|
||||
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
|
||||
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
|
||||
<span class="text-sm text-stone-500">
|
||||
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
|
||||
</span>
|
||||
<span class="text-sm text-stone-400 ml-auto">
|
||||
{% if item.published %}
|
||||
{{ item.published.strftime('%b %d, %H:%M') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if item.summary %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.url and item.post_type == "remote" %}
|
||||
<a href="{{ item.url }}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">
|
||||
original
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if actor %}
|
||||
<div id="interactions-{{ item.object_id | replace('/', '_') | replace(':', '_') }}">
|
||||
{% include "federation/_interaction_buttons.html" with context %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
11
templates/federation/_timeline_items.html
Normal file
11
templates/federation/_timeline_items.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% for item in items %}
|
||||
{% include "federation/_post_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
45
templates/federation/actor_card.html
Normal file
45
templates/federation/actor_card.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{% if result.icon_url %}
|
||||
<img src="{{ result.icon_url }}" alt="" class="w-16 h-16 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
|
||||
{{ result.preferred_username[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-bold text-lg">{{ result.display_name or result.preferred_username }}</span>
|
||||
<span class="text-stone-500">@{{ result.preferred_username }}@{{ result.domain }}</span>
|
||||
</div>
|
||||
|
||||
{% if result.summary %}
|
||||
<div class="text-sm text-stone-600 mt-1 prose prose-sm prose-stone max-w-none">
|
||||
{{ result.summary | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if actor %}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form method="post" action="{{ url_for('social.follow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">
|
||||
Follow
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('social.unfollow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
|
||||
<button type="submit"
|
||||
class="border border-stone-300 text-stone-700 px-4 py-1.5 rounded text-sm hover:bg-stone-100">
|
||||
Unfollow
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Rose Ash{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900 min-h-screen">
|
||||
<nav class="bg-stone-800 text-white p-4">
|
||||
@@ -13,6 +14,13 @@
|
||||
<div class="flex items-center gap-4">
|
||||
{% if g.user %}
|
||||
{% if actor %}
|
||||
<a href="{{ url_for('social.home_timeline') }}" class="hover:underline">Timeline</a>
|
||||
<a href="{{ url_for('social.public_timeline') }}" class="hover:underline">Public</a>
|
||||
<a href="{{ url_for('social.search') }}" class="hover:underline">Search</a>
|
||||
<a href="{{ url_for('social.notifications') }}" class="hover:underline relative">
|
||||
Notifications
|
||||
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML" class="absolute -top-2 -right-4"></span>
|
||||
</a>
|
||||
<a href="{{ url_for('actors.profile', username=actor.preferred_username) }}" class="hover:underline">
|
||||
@{{ actor.preferred_username }}
|
||||
</a>
|
||||
|
||||
34
templates/federation/compose.html
Normal file
34
templates/federation/compose.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "federation/base.html" %}
|
||||
|
||||
{% block title %}Compose — Rose Ash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Compose</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('social.compose_submit') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if reply_to %}
|
||||
<input type="hidden" name="in_reply_to" value="{{ reply_to }}">
|
||||
<div class="text-sm text-stone-500">
|
||||
Replying to <span class="font-mono">{{ reply_to }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<textarea name="content" rows="6" maxlength="5000" required
|
||||
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?"></textarea>
|
||||
|
||||
<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>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="followers">Followers only</option>
|
||||
</select>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
templates/federation/notifications.html
Normal file
17
templates/federation/notifications.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "federation/base.html" %}
|
||||
|
||||
{% block title %}Notifications — Rose Ash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Notifications</h1>
|
||||
|
||||
{% if not notifications %}
|
||||
<p class="text-stone-500">No notifications yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for notif in notifications %}
|
||||
{% include "federation/_notification.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
templates/federation/search.html
Normal file
27
templates/federation/search.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "federation/base.html" %}
|
||||
|
||||
{% block title %}Search — Rose Ash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('social.search') }}" class="mb-6">
|
||||
<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="@user@instance.tld">
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if query and not result %}
|
||||
<p class="text-stone-500">No results found for <strong>{{ query }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
{% include "federation/actor_card.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
19
templates/federation/timeline.html
Normal file
19
templates/federation/timeline.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "federation/base.html" %}
|
||||
|
||||
{% block title %}{{ "Home" if timeline_type == "home" else "Public" }} Timeline — Rose Ash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1>
|
||||
{% if actor %}
|
||||
<a href="{{ url_for('social.compose_form') }}"
|
||||
class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">
|
||||
Compose
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="timeline">
|
||||
{% include "federation/_timeline_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user