Files
mono/federation/sx/social.sx
giles 436848060d Merge branch 'worktree-sx-loop-cleanup' into macros
# Conflicts:
#	blog/sx/sx_components.py
#	federation/sx/profile.sx
#	federation/sx/sx_components.py
#	orders/sx/sx_components.py
2026-03-05 16:08:36 +00:00

298 lines
15 KiB
Plaintext

;; Social navigation, header, post cards, timeline, compose
;; --- Navigation ---
(defcomp ~federation-nav-choose-username (&key url)
(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")))
(defcomp ~federation-nav-notification-link (&key href cls count-url)
(a :href href :class cls "Notifications"
(span :sx-get count-url :sx-trigger "load, every 30s" :sx-swap "innerHTML"
:class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden")))
(defcomp ~federation-nav-bar (&key items)
(nav :class "flex gap-3 text-sm items-center flex-wrap" items))
(defcomp ~federation-social-header (&key nav)
(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" nav)))
;; --- Post card ---
(defcomp ~federation-boost-label (&key name)
(div :class "text-sm text-stone-500 mb-2" "Boosted by " name))
;; Aliases — delegate to shared ~avatar
(defcomp ~federation-avatar-img (&key src cls)
(~avatar :src src :cls cls))
(defcomp ~federation-avatar-placeholder (&key cls initial)
(~avatar :cls cls :initial initial))
(defcomp ~federation-content (&key content summary)
(if summary
(details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content))))
(defcomp ~federation-original-link (&key url)
(a :href url :target "_blank" :rel "noopener"
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
(defcomp ~federation-post-card (&key boost avatar actor-name actor-username domain time content original interactions)
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
boost
(div :class "flex items-start gap-3"
avatar
(div :class "flex-1 min-w-0"
(div :class "flex items-baseline gap-2"
(span :class "font-semibold text-stone-900" actor-name)
(span :class "text-sm text-stone-500" "@" actor-username domain)
(span :class "text-sm text-stone-400 ml-auto" time))
content original interactions))))
;; --- Interaction buttons ---
(defcomp ~federation-reply-link (&key url)
(a :href url :class "hover:text-stone-700" "Reply"))
(defcomp ~federation-like-form (&key action target oid ainbox csrf cls icon count)
(form :sx-post action :sx-target target :sx-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 icon) " " count)))
(defcomp ~federation-boost-form (&key action target oid ainbox csrf cls count)
(form :sx-post action :sx-target target :sx-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") " " count)))
(defcomp ~federation-interaction-buttons (&key like boost reply)
(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"
like boost reply))
;; --- Timeline ---
(defcomp ~federation-scroll-sentinel (&key url)
(div :sx-get url :sx-trigger "revealed" :sx-swap "outerHTML"))
(defcomp ~federation-compose-button (&key url)
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
(defcomp ~federation-timeline-page (&key label compose timeline)
(div :class "flex items-center justify-between mb-6"
(h1 :class "text-2xl font-bold" label " Timeline")
compose)
(div :id "timeline" timeline))
;; --- Data-driven post card (replaces Python _post_card_sx loop) ---
(defcomp ~federation-post-card-from-data (&key d has-actor csrf
like-url unlike-url
boost-url unboost-url)
(let* ((boosted-by (get d "boosted_by"))
(actor-icon (get d "actor_icon"))
(actor-name (get d "actor_name"))
(initial (or (get d "initial") "?"))
(avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial)))
(boost (when boosted-by (~federation-boost-label :name boosted-by)))
(content-sx (if (get d "summary")
(~federation-content :content (get d "content") :summary (get d "summary"))
(~federation-content :content (get d "content"))))
(original (when (get d "original_url")
(~federation-original-link :url (get d "original_url"))))
(safe-id (get d "safe_id"))
(interactions (when has-actor
(let* ((oid (get d "object_id"))
(ainbox (get d "author_inbox"))
(target (str "#interactions-" safe-id))
(liked (get d "liked_by_me"))
(boosted-me (get d "boosted_by_me"))
(l-action (if liked unlike-url like-url))
(l-cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")))
(l-icon (if liked "\u2665" "\u2661"))
(b-action (if boosted-me unboost-url boost-url))
(b-cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")))
(reply-url (get d "reply_url"))
(reply (when reply-url (~federation-reply-link :url reply-url)))
(like-form (~federation-like-form
:action l-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls l-cls :icon l-icon :count (get d "like_count")))
(boost-form (~federation-boost-form
:action b-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls b-cls :count (get d "boost_count"))))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons :like like-form :boost boost-form :reply reply))))))
(~federation-post-card
:boost boost :avatar avatar
:actor-name actor-name :actor-username (get d "actor_username")
:domain (get d "domain") :time (get d "time")
:content content-sx :original original
:interactions interactions)))
;; Data-driven timeline items (replaces Python _timeline_items_sx loop)
(defcomp ~federation-timeline-items-from-data (&key items next-url has-actor csrf
like-url unlike-url boost-url unboost-url)
(<>
(map (lambda (d)
(~federation-post-card-from-data :d d :has-actor has-actor :csrf csrf
:like-url like-url :unlike-url unlike-url :boost-url boost-url :unboost-url unboost-url))
(or items (list)))
(when next-url (~federation-scroll-sentinel :url next-url))))
;; --- Compose ---
(defcomp ~federation-compose-reply (&key 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)))
(defcomp ~federation-compose-form (&key action csrf reply)
(h1 :class "text-2xl font-bold mb-6" "Compose")
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
reply
(textarea :name "content" :rows "6" :maxlength "5000" :required true
:class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"
:placeholder "What's on your mind?")
(div :class "flex items-center justify-between"
(select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"
(option :value "public" "Public")
(option :value "unlisted" "Unlisted")
(option :value "followers" "Followers only"))
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish"))))
;; ---------------------------------------------------------------------------
;; Assembled social nav — replaces Python _social_nav_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-social-nav (&key actor)
(if (not actor)
(~federation-nav-choose-username :url (url-for "identity.choose_username_form"))
(let* ((rp (request-path))
(links (list
(dict :endpoint "social.defpage_home_timeline" :label "Timeline")
(dict :endpoint "social.defpage_public_timeline" :label "Public")
(dict :endpoint "social.defpage_compose_form" :label "Compose")
(dict :endpoint "social.defpage_following_list" :label "Following")
(dict :endpoint "social.defpage_followers_list" :label "Followers")
(dict :endpoint "social.defpage_search" :label "Search"))))
(~federation-nav-bar
:items (<>
(map (lambda (lnk)
(let* ((href (url-for (get lnk "endpoint")))
(bold (if (= rp href) " font-bold" "")))
(a :href href
:class (str "px-2 py-1 rounded hover:bg-stone-200" bold)
(get lnk "label"))))
links)
(let* ((notif-url (url-for "social.defpage_notifications"))
(notif-bold (if (= rp notif-url) " font-bold" "")))
(~federation-nav-notification-link
:href notif-url
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
:count-url (url-for "social.notification_count")))
(a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username"))
:class "px-2 py-1 rounded hover:bg-stone-200"
(str "@" (get actor "preferred_username"))))))))
;; ---------------------------------------------------------------------------
;; Assembled post card — replaces Python _post_card_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-post-card-from-data (&key item actor)
(let* ((boosted-by (get item "boosted_by"))
(actor-icon (get item "actor_icon"))
(actor-name (or (get item "actor_name") "?"))
(actor-username (or (get item "actor_username") ""))
(actor-domain (or (get item "actor_domain") ""))
(content (or (get item "content") ""))
(summary (get item "summary"))
(published (or (get item "published") ""))
(url (get item "url"))
(post-type (or (get item "post_type") ""))
(oid (or (get item "object_id") ""))
(safe-id (replace (replace oid "/" "_") ":" "_"))
(initial (if (and (not actor-icon) actor-name)
(upper (slice actor-name 0 1)) "?")))
(~federation-post-card
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by)))
:avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial))
:actor-name (escape actor-name)
:actor-username (escape actor-username)
:domain (if actor-domain (str "@" (escape actor-domain)) "")
:time published
:content (if summary
(~federation-content :content content :summary (escape summary))
(~federation-content :content content))
:original (when (and url (= post-type "remote"))
(~federation-original-link :url url))
:interactions (when actor
(let* ((csrf (csrf-token))
(liked (get item "liked_by_me"))
(boosted-me (get item "boosted_by_me"))
(lcount (or (get item "like_count") 0))
(bcount (or (get item "boost_count") 0))
(ainbox (or (get item "author_inbox") ""))
(target (str "#interactions-" safe-id)))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons
:like (~federation-like-form
:action (url-for (if liked "social.unlike" "social.like"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))
:icon (if liked "\u2665" "\u2661") :count (str lcount))
:boost (~federation-boost-form
:action (url-for (if boosted-me "social.unboost" "social.boost"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))
:count (str bcount))
:reply (when oid
(~federation-reply-link
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
;; ---------------------------------------------------------------------------
;; Assembled timeline items — replaces Python _timeline_items_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-timeline-items (&key items timeline-type actor next-url)
(<>
(map (lambda (item)
(~federation-post-card-from-data :item item :actor actor))
items)
(when next-url
(~federation-scroll-sentinel :url next-url))))
;; Assembled timeline content — replaces Python _timeline_content_sx
(defcomp ~federation-timeline-content (&key items timeline-type actor)
(let* ((label (if (= timeline-type "home") "Home" "Public")))
(~federation-timeline-page
:label label
:compose (when actor
(~federation-compose-button :url (url-for "social.defpage_compose_form")))
:timeline (~federation-timeline-items
:items items :timeline-type timeline-type :actor actor
:next-url (when (not (empty? items))
(url-for (str "social." timeline-type "_timeline_page")
:before (get (last items) "before_cursor")))))))
;; Assembled compose content — replaces Python _compose_content_sx
(defcomp ~federation-compose-content (&key reply-to)
(~federation-compose-form
:action (url-for "social.compose_submit")
:csrf (csrf-token)
:reply (when reply-to
(~federation-compose-reply :reply-to (escape reply-to)))))