;; 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)))))