112 conversions across 19 .sx files using match, let-match, and pipe operators: match (17): type/value dispatch replacing cond/if chains - lib/vm.sx: HO form dispatch (for-each/map/filter/reduce/some/every?) - lib/tree-tools.sx: node-display, node-matches?, rename, count, replace, free-symbols - lib/types.sx: narrow-type, substitute-in-type, infer-type, resolve-type - web/engine.sx: default-trigger, resolve-target, classify-trigger - web/deps.sx: scan-refs-walk, scan-io-refs-walk let-match (89): dict destructuring replacing (get d "key") patterns - shared/page-functions.sx (20), blog/admin.sx (17), pub-api.sx (13) - events/ layouts/page/tickets/entries/forms (27 total) - specs-explorer.sx (7), federation/social.sx (3), lib/ small files (3) -> pipes (6): replacing triple-chained gets in lib/vm.sx - frame-closure → closure-code → code-bytecode chains Also: lib/vm.sx accessor upgrades (get vm "sp" → vm-sp vm throughout) 2650/2650 tests pass, zero regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
359 lines
16 KiB
Plaintext
359 lines
16 KiB
Plaintext
;; Social navigation, header, post cards, timeline, compose
|
|
|
|
;; --- Navigation ---
|
|
|
|
(defcomp ~social/nav-choose-username (&key (url :as string))
|
|
(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 ~social/nav-notification-link (&key (href :as string) (cls :as string) (count-url :as string))
|
|
(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 ~social/nav-bar (&key items)
|
|
(nav :class "flex gap-3 text-sm items-center flex-wrap" items))
|
|
|
|
(defcomp ~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 ~social/boost-label (&key (name :as string))
|
|
(div :class "text-sm text-stone-500 mb-2" "Boosted by " name))
|
|
|
|
;; Aliases — delegate to shared ~shared:misc/avatar
|
|
(defcomp ~social/avatar-img (&key (src :as string) (cls :as string))
|
|
(~shared:misc/avatar :src src :cls cls))
|
|
|
|
(defcomp ~social/avatar-placeholder (&key (cls :as string) (initial :as string))
|
|
(~shared:misc/avatar :cls cls :initial initial))
|
|
|
|
(defcomp ~social/content (&key (content :as string) (summary :as string?))
|
|
(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 ~social/original-link (&key (url :as string))
|
|
(a :href url :target "_blank" :rel "noopener"
|
|
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
|
|
|
|
(defcomp ~social/post-card (&key boost avatar (actor-name :as string) (actor-username :as string) (domain :as string) (time :as string) 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 ~social/reply-link (&key (url :as string))
|
|
(a :href url :class "hover:text-stone-700" "Reply"))
|
|
|
|
(defcomp ~social/like-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) (icon :as string) 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 ~social/boost-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) 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 ~social/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 ~social/scroll-sentinel (&key (url :as string))
|
|
(div :sx-get url :sx-trigger "revealed" :sx-swap "outerHTML"))
|
|
|
|
(defcomp ~social/compose-button (&key (url :as string))
|
|
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
|
|
|
|
(defcomp ~social/timeline-page (&key (label :as string) 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
|
|
~social/post-card-from-data
|
|
(&key
|
|
(d :as dict)
|
|
(has-actor :as boolean)
|
|
(csrf :as string)
|
|
(like-url :as string)
|
|
(unlike-url :as string)
|
|
(boost-url :as string)
|
|
(unboost-url :as string))
|
|
(let-match
|
|
{:actor_name actor-name :liked_by_me liked :boosted_by_me boosted-me :time time :actor_username actor-username :domain domain :content content :object_id oid :boosted_by boosted-by :summary summary :original_url original-url :safe_id safe-id :author_inbox ainbox :reply_url reply-url :like_count like-count :boost_count boost-count :actor_icon actor-icon :initial initial*}
|
|
d
|
|
(let*
|
|
((initial (or initial* "?"))
|
|
(avatar
|
|
(~shared:misc/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 (~social/boost-label :name boosted-by)))
|
|
(content-sx
|
|
(if
|
|
summary
|
|
(~social/content :content content :summary summary)
|
|
(~social/content :content content)))
|
|
(original
|
|
(when original-url (~social/original-link :url original-url)))
|
|
(interactions
|
|
(when
|
|
has-actor
|
|
(let*
|
|
((target (str "#interactions-" safe-id))
|
|
(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 "♥" "♡"))
|
|
(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 (when reply-url (~social/reply-link :url reply-url)))
|
|
(like-form
|
|
(~social/like-form
|
|
:action l-action
|
|
:target target
|
|
:oid oid
|
|
:ainbox ainbox
|
|
:csrf csrf
|
|
:cls l-cls
|
|
:icon l-icon
|
|
:count like-count))
|
|
(boost-form
|
|
(~social/boost-form
|
|
:action b-action
|
|
:target target
|
|
:oid oid
|
|
:ainbox ainbox
|
|
:csrf csrf
|
|
:cls b-cls
|
|
:count boost-count)))
|
|
(div
|
|
:id (str "interactions-" safe-id)
|
|
(~social/interaction-buttons
|
|
:like like-form
|
|
:boost boost-form
|
|
:reply reply))))))
|
|
(~social/post-card
|
|
:boost boost
|
|
:avatar avatar
|
|
:actor-name actor-name
|
|
:actor-username actor-username
|
|
:domain domain
|
|
:time time
|
|
:content content-sx
|
|
:original original
|
|
:interactions interactions))))
|
|
|
|
;; Data-driven timeline items (replaces Python _timeline_items_sx loop)
|
|
(defcomp ~social/timeline-items-from-data (&key (items :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string)
|
|
(like-url :as string) (unlike-url :as string) (boost-url :as string) (unboost-url :as string))
|
|
(<>
|
|
(map (lambda (d)
|
|
(~social/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 (~social/scroll-sentinel :url next-url))))
|
|
|
|
;; --- Compose ---
|
|
|
|
(defcomp ~social/compose-reply (&key (reply-to :as string))
|
|
(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 ~social/compose-form (&key (action :as string) (csrf :as string) 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
|
|
~social/nav
|
|
(&key actor)
|
|
(if
|
|
(not actor)
|
|
(~social/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"))))
|
|
(~social/nav-bar
|
|
:items (<>
|
|
(map
|
|
(lambda
|
|
(lnk)
|
|
(let-match
|
|
{:label label :endpoint endpoint}
|
|
lnk
|
|
(let*
|
|
((href (url-for endpoint))
|
|
(bold (if (= rp href) " font-bold" "")))
|
|
(a
|
|
:href href
|
|
:class (str "px-2 py-1 rounded hover:bg-stone-200" bold)
|
|
label))))
|
|
links)
|
|
(let*
|
|
((notif-url (url-for "social.defpage_notifications"))
|
|
(notif-bold (if (= rp notif-url) " font-bold" "")))
|
|
(~social/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")))
|
|
(let-match
|
|
{:preferred_username username}
|
|
actor
|
|
(a
|
|
:href (url-for "activitypub.actor_profile" :username username)
|
|
:class "px-2 py-1 rounded hover:bg-stone-200"
|
|
(str "@" username))))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Assembled post card — replaces Python _post_card_sx
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~social/post-card-from-data (&key (item :as dict) 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)) "?")))
|
|
(~social/post-card
|
|
:boost (when boosted-by (~social/boost-label :name (escape boosted-by)))
|
|
:avatar (~shared:misc/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
|
|
(~social/content :content content :summary (escape summary))
|
|
(~social/content :content content))
|
|
:original (when (and url (= post-type "remote"))
|
|
(~social/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)
|
|
(~social/interaction-buttons
|
|
:like (~social/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 (~social/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
|
|
(~social/reply-link
|
|
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Assembled timeline items — replaces Python _timeline_items_sx
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~social/timeline-items (&key (items :as list) (timeline-type :as string) actor (next-url :as string?))
|
|
(<>
|
|
(map (lambda (item)
|
|
(~social/post-card-from-data :item item :actor actor))
|
|
items)
|
|
(when next-url
|
|
(~social/scroll-sentinel :url next-url))))
|
|
|
|
;; Assembled timeline content — replaces Python _timeline_content_sx
|
|
(defcomp ~social/timeline-content (&key (items :as list) (timeline-type :as string) actor)
|
|
(let* ((label (if (= timeline-type "home") "Home" "Public")))
|
|
(~social/timeline-page
|
|
:label label
|
|
:compose (when actor
|
|
(~social/compose-button :url (url-for "social.defpage_compose_form")))
|
|
:timeline (~social/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 ~social/compose-content (&key (reply-to :as string?))
|
|
(~social/compose-form
|
|
:action (url-for "social.compose_submit")
|
|
:csrf (csrf-token)
|
|
:reply (when reply-to
|
|
(~social/compose-reply :reply-to (escape reply-to)))))
|