From 5e4ccdc802c97c4221c4965ae5f729f7c2aabe75 Mon Sep 17 00:00:00 2001 From: cashmere Date: Thu, 30 Jul 2026 10:02:53 +0200 Subject: track lisp/feed-ui.el --- lisp/feed-ui.el | 787 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 787 insertions(+) create mode 100644 lisp/feed-ui.el (limited to 'lisp') diff --git a/lisp/feed-ui.el b/lisp/feed-ui.el new file mode 100644 index 0000000..ed15fba --- /dev/null +++ b/lisp/feed-ui.el @@ -0,0 +1,787 @@ +;;; feed-ui.el --- A vui client for feed.org -*- lexical-binding: t -*- + +;; A vui.el front-end for the git-powered decentralized feed living in +;; feed.org. The org file stays the single source of truth; this file +;; is only a client. All git operations and post parsing reuse the +;; `feed//...' engine defined in feed.org's `engine' src block - open +;; feed.org once (accepting the file-local variables) before `M-x feed'. + +;; Layout: borderless, left-aligned. Cards carry the thread semantics: +;; ● own root post ○ other root post ◐ reply +;; Views: timeline -> thread (org-like tree) -> profile / tag, with a +;; navigation stack (q goes back, then quits). +;; Keys: RET thread · r reply · m mute · g sync · q back/quit. + +;;; Code: + +(require 'vui) +(require 'vui-components) +(require 'cl-lib) + +;;; § 1 Engine bridge + +(defgroup feed-ui nil + "vui client for feed.org." + :group 'applications) + +(defcustom feed-ui-file + (expand-file-name "feed.org" + (file-name-directory + (or load-file-name buffer-file-name default-directory))) + "Path to the feed.org console file." + :type 'file + :group 'feed-ui) + +;; Defined by feed.org's `engine' block: +(defvar feed/file) +(declare-function feed//collect "feed") +(declare-function feed//newer "feed") +(declare-function feed//seconds "feed") +(declare-function feed//pending-all "feed") +(declare-function feed//own-files "feed") +(declare-function feed//pick-own-file "feed") +(declare-function feed//git "feed") +(declare-function feed//approved-branch "feed") +(declare-function feed//remote-branch "feed") +(declare-function feed/sync "feed") +(declare-function feed/approve "feed") +(declare-function feed/review "feed") +(declare-function feed/publish "feed") + +(defun feed-ui--ensure-engine () + "Make sure the feed.org engine is loaded, or signal a helpful error." + (unless (fboundp 'feed//collect) + (when (file-exists-p feed-ui-file) + (find-file-noselect feed-ui-file)) ; file-local eval loads the engine + (unless (fboundp 'feed//collect) + (user-error "feed-ui: engine not loaded - open %s and accept the file-local variables" + feed-ui-file)))) + +;;; § 2 Data layer +;; +;; The engine yields posts as (ts nick body reply-to tags). The client +;; adapts them to plists once; every view works with these. + +(defun feed-ui--post-plist (post) + "Adapt an engine POST tuple to a plist. +:id is \"nick/ts\" - the same string a :REPLY_TO: property points at, +so it doubles as the threading key and the vui reconciliation key." + (pcase-let ((`(,ts ,nick ,body ,reply-to ,tags) post)) + (list :id (concat nick "/" ts) + :ts ts + :nick nick + :body body + :reply-to reply-to + :tags tags + :seconds (feed//seconds ts)))) + +(defun feed-ui--posts () + "Collect every approved post, newest first, as plists." + (mapcar #'feed-ui--post-plist (sort (feed//collect) #'feed//newer))) + +(defun feed-ui--own-nicks () + "Nicks hosted by this repo (basenames of local .org files)." + (mapcar #'file-name-base (feed//own-files))) + +(defun feed-ui--ago (seconds) + "Human relative time for SECONDS since epoch." + (let ((d (- (float-time) seconds))) + (cond ((< d 90) "now") + ((< d 3600) (format "%dm" (floor d 60))) + ((< d 86400) (format "%dh" (floor d 3600))) + ((< d (* 7 86400)) (format "%dd" (floor d 86400))) + (t (format-time-string "%Y-%m-%d" (seconds-to-time seconds)))))) + +(defun feed-ui--children-of (posts id) + "Direct replies to the post ID, oldest first (conversation order)." + (sort (cl-remove-if-not (lambda (p) (equal (plist-get p :reply-to) id)) + posts) + (lambda (a b) (< (plist-get a :seconds) (plist-get b :seconds))))) + +(defun feed-ui--reply-counts (posts) + "Hash post-id -> number of direct replies." + (let ((counts (make-hash-table :test 'equal))) + (dolist (p posts counts) + (when-let* ((parent (plist-get p :reply-to))) + (puthash parent (1+ (gethash parent counts 0)) counts))))) + +(defun feed-ui--pending-posts (nick) + "Posts that NICK's unapproved commits would add, oldest commit first. +Parses the added lines of `git log --patch' over the pending range: +every added `** TIMESTAMP' heading starts a post, the following added +lines (property drawer excluded) are its body." + (let* ((approved (feed//approved-branch nick)) + (branch (feed//remote-branch nick)) + (range (if approved (concat approved ".." branch) branch)) + (diff (or (feed//git (list "log" "--patch" range)) "")) + (added nil)) + (dolist (line (split-string diff "\n")) + (when (and (string-prefix-p "+" line) + (not (string-prefix-p "+++" line))) + (push (substring line 1) added))) + (let ((posts nil) (cur nil) (in-drawer nil)) + (dolist (line (nreverse added)) + (cond + ;; new post heading: capture the timestamp title + ((string-match "\\`\\*+ \\([0-9][^ +]*\\)" line) + (when cur (push cur posts)) + (setq cur (list (match-string 1 line) nil) + in-drawer nil)) + ((string-prefix-p ":PROPERTIES:" (string-trim line)) + (setq in-drawer t)) + ((string-prefix-p ":END:" (string-trim line)) + (setq in-drawer nil)) + ((and cur (not in-drawer)) + (push line (cadr cur))))) + (when cur (push cur posts)) + (mapcar (lambda (p) + (list :ts (car p) + :body (string-trim (string-join (nreverse (cadr p)) "\n")))) + (nreverse posts))))) + +;;; § 3 Atoms + +(defun feed-ui--tokens (text) + "Split TEXT into (RAW . DISPLAY-WIDTH) tokens. +Org links are atomic: the raw form keeps the full [[url][desc]] syntax +(so the line renderer can still make it a button), while the display +width counts only what the button will show." + (let ((tokens nil) (pos 0)) + (while (< pos (length text)) + (cond + ((string-match "\\[\\[\\([^]\n]+\\)\\]\\(?:\\[\\([^]\n]+\\)\\]\\)?\\]" text pos) + ;; Capture match data BEFORE `split-string' clobbers it. + (let ((beg (match-beginning 0)) + (end (match-end 0)) + (raw (match-string 0 text)) + (disp (or (match-string 2 text) (match-string 1 text)))) + (when (> beg pos) + (dolist (word (split-string (substring text pos beg) nil t)) + (push (cons word (string-width word)) tokens))) + (push (cons raw (string-width disp)) tokens) + (setq pos end))) + (t + (dolist (word (split-string (substring text pos) nil t)) + (push (cons word (string-width word)) tokens)) + (setq pos (length text))))) + (nreverse tokens))) + +(defun feed-ui--wrap (text width) + "Greedy word-wrap TEXT to WIDTH display columns. +Returns a list of lines. Org links are never split across lines." + (let ((lines nil) (cur nil) (cur-w 0)) + (dolist (tok (feed-ui--tokens text)) + (if (and cur (> (+ cur-w 1 (cdr tok)) width)) + (progn + (push (mapconcat #'car (nreverse cur) " ") lines) + (setq cur (list tok) + cur-w (cdr tok))) + (push tok cur) + (setq cur-w (if (cdr cur) (+ cur-w 1 (cdr tok)) (cdr tok))))) + (when cur + (push (mapconcat #'car (nreverse cur) " ") lines)) + (nreverse (or lines (list ""))))) + +(defun feed-ui--line-vnode (line) + "Render one body LINE, turning [[url][desc]] org links into buttons." + (let ((parts nil) (pos 0)) + (while (string-match "\\[\\[\\([^]\n]+\\)\\]\\(?:\\[\\([^]\n]+\\)\\]\\)?\\]" line pos) + (let ((url (match-string 1 line)) + (desc (or (match-string 2 line) (match-string 1 line))) + (start (match-beginning 0))) + (push (vui-text (substring line pos start)) parts) + (push (vui-button desc + :on-click (lambda () (browse-url url)) + :no-decoration t + :face 'link + :help-echo nil) + parts) + (setq pos (match-end 0)))) + (push (vui-text (substring line pos)) parts) + (if (cdr parts) + (apply #'vui-hstack :spacing 0 (nreverse parts)) + (car parts)))) + +(defun feed-ui--body-vnodes (body) + "Render post BODY as a list of vnodes, one per line. +Each source line is word-wrapped so the rendered card, indent +included, stays within `fill-column'." + (let ((width (max 20 (- fill-column 2)))) + (mapcan (lambda (line) + (mapcar #'feed-ui--line-vnode + (if (string-empty-p (string-trim line)) + (list "") + (feed-ui--wrap line width)))) + (split-string body "\n")))) + +(defun feed-ui--tag-pill (tag on-click) + "Render TAG as a quiet clickable pill calling ON-CLICK." + (vui-button (concat ":" tag ":") + :on-click on-click + :no-decoration t + :face 'shadow + :help-echo nil)) + +;;; § 4 Post card - the shared molecule + +(defun feed-ui--card (post own-p reply-count on-nav &optional guide full-ts) + "Render POST as a card. OWN-P marks posts by this repo's own nicks. +REPLY-COUNT decorates the thread button. ON-NAV is called with +\(view . args) for profile/thread/tag navigation. GUIDE is an optional +tree-guide prefix (\"├─\"/\"└─\") for the meta line; FULL-TS shows the +raw timestamp instead of relative time. +Every interactive child carries the post id as :key so point-based +commands (RET/r/m) can find the post under the cursor via `vui-key-at'." + (let* ((id (plist-get post :id)) + (nick (plist-get post :nick)) + (reply-to (plist-get post :reply-to)) + (marker (cond (reply-to "◐") (own-p "●") (t "○"))) + (marker-face (cond (reply-to 'warning) (own-p 'success) (t 'shadow)))) + (vui-vstack + :spacing 0 + ;; meta line: [guide] marker · nick · ago · reply indicator + (vui-hstack + :spacing 1 + (when guide (vui-text guide :face 'shadow)) + (vui-text marker :face marker-face) + (vui-button nick + :key id + :on-click (lambda () (funcall on-nav 'profile nick)) + :no-decoration t + :face (if own-p '(:inherit success :weight bold) 'bold) + :help-echo nil) + (vui-text "·" :face 'shadow) + (vui-text (if full-ts + (plist-get post :ts) + (feed-ui--ago (plist-get post :seconds))) + :face 'shadow) + (when reply-to + (list (vui-text "·" :face 'shadow) + (vui-text (format "↩ %s" (car (split-string reply-to "/"))) + :face 'shadow)))) + ;; body + (apply #'vui-vstack :indent 2 :spacing 0 + (feed-ui--body-vnodes (plist-get post :body))) + ;; tags + actions + (vui-hstack + :spacing 1 + (vui-space 2) + (mapcar (lambda (tag) + (feed-ui--tag-pill tag (lambda () (funcall on-nav 'tag tag)))) + (plist-get post :tags)) + (when (plist-get post :tags) (vui-text "·" :face 'shadow)) + (vui-button "↩ reply" + :key id + :on-click (lambda () (feed-ui-compose id)) + :no-decoration t + :face 'shadow + :help-echo nil) + (vui-text "·" :face 'shadow) + (vui-button (if (> reply-count 0) + (format "▸ thread (%d)" reply-count) + "▸ thread") + :key id + :on-click (lambda () (funcall on-nav 'thread id)) + :no-decoration t + :face 'shadow + :help-echo nil))))) + +;;; § 5 Views + +(defun feed-ui--thread-node (post posts own-nicks counts on-nav &optional guide full-ts) + "Render POST and its whole reply subtree, org-outline style. +Children are indented one level; each gets a \"├─\"/\"└─\" guide." + (let* ((id (plist-get post :id)) + (children (feed-ui--children-of posts id))) + (vui-vstack + :spacing 1 + (feed-ui--card post (member (plist-get post :nick) own-nicks) + (gethash id counts 0) on-nav guide full-ts) + (when children + (apply #'vui-vstack :indent 2 :spacing 1 + (cl-loop for child in children + for rest on children + collect (feed-ui--thread-node + child posts own-nicks counts on-nav + (if (null (cdr rest)) "└─" "├─")))))))) + +(defun feed-ui--thread-view (id posts own-nicks counts on-nav) + "Full-thread view rooted at post ID." + (let ((root (cl-find-if (lambda (p) (equal (plist-get p :id) id)) posts))) + (if (null root) + (vui-text "Post not found (not approved yet?)" :face 'warning) + (feed-ui--thread-node root posts own-nicks counts on-nav nil t)))) + +(defun feed-ui--profile-view (nick posts own-nicks counts on-nav) + "Profile view: stats header plus NICK's posts, newest first." + (let* ((mine (cl-remove-if-not (lambda (p) (equal (plist-get p :nick) nick)) posts)) + (tags (delete-dups (apply #'append + (mapcar (lambda (p) (copy-sequence (plist-get p :tags))) + mine)))) + (nreplies (cl-count-if (lambda (p) (plist-get p :reply-to)) mine))) + (if (null mine) + (vui-text "No approved posts from this nick." :face 'shadow) + (vui-vstack + :spacing 1 + (vui-text (format "%d posts · %d replies" (length mine) nreplies) + :face 'shadow) + (when tags + (vui-hstack + :spacing 1 + (mapcar (lambda (tag) + (feed-ui--tag-pill tag (lambda () (funcall on-nav 'tag tag)))) + tags))) + (vui-list mine + (lambda (post) + (feed-ui--card post + (member (plist-get post :nick) own-nicks) + (gethash (plist-get post :id) counts 0) + on-nav)) + (lambda (post) (plist-get post :id)) + :spacing 1))))) + +(defun feed-ui--tag-view (tag posts own-nicks counts on-nav) + "Every post carrying TAG, newest first." + (let ((hits (cl-remove-if-not + (lambda (p) (member tag (plist-get p :tags))) posts))) + (if (null hits) + (vui-text "No posts with this tag." :face 'shadow) + (vui-list hits + (lambda (post) + (feed-ui--card post + (member (plist-get post :nick) own-nicks) + (gethash (plist-get post :id) counts 0) + on-nav)) + (lambda (post) (plist-get post :id)) + :spacing 1)))) + +(defun feed-ui--review-view (pending on-nav) + "Review queue: one row per nick with unapproved commits." + (if (null pending) + (vui-text "Nothing pending." :face 'shadow) + (vui-vstack + :spacing 0 + (mapcar (lambda (p) + (vui-hstack + :spacing 1 + (vui-text "▸" :face 'warning) + (vui-button (car p) + :on-click (lambda () (funcall on-nav 'review-detail (car p))) + :no-decoration t + :face 'bold + :help-echo nil) + (vui-text (format "%d pending" (cdr p)) :face 'warning))) + pending)))) + +(defun feed-ui--review-detail-view (nick on-approve) + "The posts NICK's pending commits would add, plus the approve button." + (let ((pending (feed-ui--pending-posts nick))) + (vui-vstack + :spacing 1 + (if (null pending) + (vui-text "No new posts in the pending commits." :face 'shadow) + (vui-vstack + :spacing 1 + (mapcar (lambda (p) + (vui-vstack + :spacing 0 + (vui-text (format "+ %s" (plist-get p :ts)) :face 'success) + (apply #'vui-vstack :indent 2 :spacing 0 + (feed-ui--body-vnodes (plist-get p :body))))) + pending))) + (vui-hstack + :spacing 2 + (vui-button "approve" :face 'success :on-click on-approve) + (vui-text "unapproved commits never enter any timeline" :face 'shadow))))) + +;;; § 5b Compose +;; +;; A post body is org content - links, emphasis, src blocks, whole blog +;; posts - so it is edited in a real org buffer. The vui part is an +;; inline form pinned at the top (tags, reply context, actions); the +;; body is everything below the form's managed region. + +(defvar-local feed-ui--compose-reply-to nil + "Post id this compose buffer replies to, or nil.") + +(defvar-local feed-ui--compose-instance nil + "The inline vui instance managing the compose form region.") + +(defvar feed-ui--live-refresh nil + "Refresh closure stashed by the last feed-app render, or nil.") + +(vui-defcomponent feed-compose-form (reply-to) + "Inline compose form: tags field, reply context, submit actions." + :render + ;; wid-edit's `widget-before-change' rejects EVERY edit outside an + ;; editable field - including the body area below this form, which is + ;; the point of the compose buffer. vui installs it buffer-wide via + ;; `widget-setup' on every inline commit (a policy meant for dedicated + ;; vui buffers), so remove it after every mount commit. NOTE: any + ;; future inline RE-render re-arms it and the removal must be repeated + ;; (effects run post-commit); this form currently never re-renders. + (progn + (vui-use-effect () + (remove-hook 'before-change-functions 'widget-before-change t)) + (vui-vstack + :spacing 1 + (vui-hstack + :spacing 1 + (vui-box (vui-text "tags:" :face 'shadow) :width 7 :align :right) + (vui-field :size 48 :key 'tags :placeholder "emacs vui (space separated, optional)")) + (when reply-to + (vui-hstack + :spacing 1 + (vui-box (vui-text "reply:" :face 'shadow) :width 7 :align :right) + (vui-text reply-to :face 'shadow))) + (vui-hstack + :spacing 2 + (vui-box (vui-text "") :width 7) + (vui-button "post" :on-click (lambda () (feed-ui--compose-submit nil))) + (vui-button "post & publish" :on-click (lambda () (feed-ui--compose-submit t))) + (vui-button "cancel" + :on-click (lambda () (feed-ui--compose-cancel)) + :no-decoration t + :face 'shadow + :help-echo nil)) + (vui-hstack + :spacing 1 + (vui-box (vui-text "") :width 7) + (vui-text "write the body below · C-c C-c post · C-c C-k cancel" + :face 'shadow))))) + +(defun feed-ui--write-post (body tags reply-to) + "Write a complete post with BODY and TAGS into the user's own org file. +Mirrors the engine's insertion format exactly; REPLY-TO, when non-nil, +lands in the :REPLY_TO: drawer property. Returns the file written." + (let ((file (feed//pick-own-file))) + (unless (file-exists-p file) + (write-region (format "#+TITLE: %s\n\n* Journal :posts:\n" + (file-name-base file)) + nil file)) + (with-current-buffer (find-file-noselect file) + (save-excursion + (goto-char (point-min)) + (unless (re-search-forward "^\\*+ [^\n]*:posts:" nil t) + (user-error "No headline tagged :posts: in %s" file)) + (beginning-of-line) + (looking-at "^\\*+") + (forward-line 1) + (insert (format "%s %s%s\n:PROPERTIES:\n%s:END:\n\n%s\n\n" + (make-string (1+ (length (match-string 0))) ?*) + (format-time-string "%Y-%m-%dT%H:%M:%S%z") + (if tags + (concat " " + (mapconcat (lambda (tag) (concat ":" tag)) + tags "") + ":") + "") + (if reply-to (format ":REPLY_TO: %s\n" reply-to) "") + body))) + (save-buffer)) + file)) + +(defun feed-ui--compose-body () + "Buffer text below the inline form region, trimmed." + (string-trim + (buffer-substring-no-properties + (marker-position (vui-instance-region-end feed-ui--compose-instance)) + (point-max)))) + +(defun feed-ui--compose-cancel () + "Discard the compose buffer." + (interactive) + (when feed-ui--compose-instance + (vui-unmount feed-ui--compose-instance) + (setq feed-ui--compose-instance nil)) + (kill-buffer (current-buffer))) + +(defun feed-ui--compose-submit (&optional publish) + "Write the composed post; with PUBLISH, also commit and push." + (interactive) + (let* ((tags-raw (or (vui-field-value 'tags) "")) + (tags (split-string tags-raw nil t)) + (body (feed-ui--compose-body))) + (when (string-empty-p body) + (user-error "feed: body is empty")) + (feed-ui--write-post body tags feed-ui--compose-reply-to) + (feed-ui--compose-cancel) + (when feed-ui--live-refresh + (funcall feed-ui--live-refresh)) + (when publish + (feed/publish)) + (message "feed: posted%s" (if publish " and published" "")))) + +(defvar feed-ui-compose-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "C-c C-c") (lambda () (interactive) (feed-ui--compose-submit nil))) + (define-key map (kbd "C-c C-k") #'feed-ui--compose-cancel) + map) + "Keymap for `feed-ui-compose-mode'.") + +(define-derived-mode feed-ui-compose-mode org-mode "feed-compose" + "Mode for feed compose buffers: org-mode plus an inline vui form.") + +(defun feed-ui-compose (&optional reply-to) + "Open a compose buffer; with REPLY-TO, a post id to reply to." + (interactive) + (feed-ui--ensure-engine) + (let ((buf (get-buffer-create + (if reply-to + (format "*feed: reply %s*" reply-to) + "*feed: compose*")))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer)) + ;; Body area first; the form region is inserted before it. + (insert "\n\n") + (feed-ui-compose-mode) + (setq feed-ui--compose-reply-to reply-to) + (setq feed-ui--compose-instance + (vui-mount-inline + (vui-component 'feed-compose-form :reply-to reply-to) + (point-min))) + (goto-char (point-max))) + (pop-to-buffer buf))) + +;;; § 6 App + +(defvar-local feed-ui--sync-action nil + "Sync closure stashed during render (has component context).") + +(defvar-local feed-ui--back-action nil + "Back/quit closure stashed during render (has component context).") + +(defvar-local feed-ui--mute-actions nil + "Hash post-id -> mute-toggle closure stashed during render.") + +(defvar-local feed-ui--thread-actions nil + "Hash post-id -> open-thread closure stashed during render.") + +(defun feed-ui--refresh () + "Re-collect posts and pending counts into the app's state. +Must run inside a component context (button callback or +`vui-with-async-context')." + (vui-batch + (vui-set-state :posts (feed-ui--posts)) + (vui-set-state :pending (or (feed//pending-all) '())) + (vui-set-state :last-sync (float-time)))) + +(vui-defcomponent feed-app () + "Root component: header, routed view (timeline/thread/profile/tag), +statusline. Navigation is a stack: drilling in pushes, `q' pops." + :state ((posts (feed-ui--posts)) + (pending (or (feed//pending-all) '())) + (own-nicks (feed-ui--own-nicks)) + (muted '()) + (view 'timeline) + (view-args nil) + (nav-stack '()) + (last-sync nil)) + :render + (let* ((counts (feed-ui--reply-counts posts)) + (pending-count (cl-reduce #'+ (mapcar #'cdr pending) :initial-value 0)) + (nav (lambda (v args) + (vui-batch + (vui-set-state :nav-stack (cons (cons view view-args) nav-stack)) + (vui-set-state :view v) + (vui-set-state :view-args args)))) + (view-label + (pcase view + ('timeline nil) + ('thread (format "thread · %s" view-args)) + ('profile (format "profile · %s" view-args)) + ('tag (format "tag · :%s:" view-args)) + ('review "review queue") + ('review-detail (format "review · %s" view-args)))) + (back (lambda () + (if (null nav-stack) + (quit-window) + (vui-batch + (vui-set-state :view (caar nav-stack)) + (vui-set-state :view-args (cdar nav-stack)) + (vui-set-state :nav-stack (cdr nav-stack))))))) + ;; Stash actions for mode-keymap commands (no component context there). + (setq feed-ui--live-refresh + (vui-with-async-context (feed-ui--refresh))) + (setq feed-ui--sync-action + (vui-with-async-context + (message "feed: syncing…") + (feed/sync) + (feed-ui--refresh))) + (setq feed-ui--back-action + (vui-with-async-context (funcall back))) + (setq feed-ui--mute-actions (make-hash-table :test 'equal)) + (setq feed-ui--thread-actions (make-hash-table :test 'equal)) + (dolist (p posts) + (let ((id (plist-get p :id)) + (nick (plist-get p :nick))) + (puthash id + (vui-with-async-context + (vui-set-state :muted (if (member nick muted) + (delete nick muted) + (cons nick muted)))) + feed-ui--mute-actions) + (puthash id + (vui-with-async-context (funcall nav 'thread id)) + feed-ui--thread-actions))) + (vui-vstack + :spacing 1 + ;; header + (vui-hstack + :spacing 2 + (vui-heading-2 "feed") + (when nav-stack + (vui-button "‹ back" + :on-click (lambda () (funcall feed-ui--back-action)) + :no-decoration t + :face 'shadow + :help-echo nil)) + (when (> pending-count 0) + (vui-button (format "%d pending review" pending-count) + :face 'warning + :on-click (lambda () (funcall nav 'review nil))))) + (vui-hstack + :spacing 2 + (vui-button "new post" :on-click (lambda () (feed-ui-compose))) + (vui-button "sync" :on-click (lambda () (funcall feed-ui--sync-action))) + (vui-button "refresh" :on-click (lambda () (feed-ui--refresh)))) + (when view-label + (vui-text view-label :face 'shadow)) + (when muted + (vui-hstack + :spacing 1 + (vui-text "muted:" :face 'shadow) + (mapcar (lambda (nick) + (vui-button nick + :on-click (lambda () (vui-set-state :muted (delete nick muted))) + :no-decoration t + :face 'shadow + :help-echo "unmute")) + muted))) + ;; routed view + (pcase view + ('timeline + (let ((visible (cl-remove-if-not + (lambda (p) (not (member (plist-get p :nick) muted))) + posts))) + (if (null visible) + (vui-vstack + :spacing 1 + (vui-text "Nothing here yet." :face 'shadow) + (vui-button "write the first post" + :on-click (lambda () (feed-ui-compose)))) + (vui-list visible + (lambda (post) + (feed-ui--card post + (member (plist-get post :nick) own-nicks) + (gethash (plist-get post :id) counts 0) + nav)) + (lambda (post) (plist-get post :id)) + :spacing 1)))) + ('thread (feed-ui--thread-view view-args posts own-nicks counts nav)) + ('profile (feed-ui--profile-view view-args posts own-nicks counts nav)) + ('tag (feed-ui--tag-view view-args posts own-nicks counts nav)) + ('review (feed-ui--review-view pending nav)) + ('review-detail + (feed-ui--review-detail-view + view-args + (lambda () + (feed/approve view-args) + (feed-ui--refresh) + (funcall back))))) + ;; statusline + (vui-vstack + :spacing 0 + (vui-text + (format "%d posts · %d nicks%s" + (length posts) + (length (delete-dups (mapcar (lambda (p) (plist-get p :nick)) posts))) + (if last-sync + (format " · synced %s" (feed-ui--ago last-sync)) + "")) + :face 'shadow) + (vui-text "RET thread · r reply · m mute · g sync · q back/quit" + :face 'shadow))))) + +;;; § 7 Mode and entry point + +(defun feed-ui--key-near (pos) + "Post id at POS, scanning backward for the nearest keyed element. +Card bodies are plain text; the keyed buttons sit on the meta line +above and the actions row below." + (or (vui-key-at pos) + (cl-loop for p from (1- pos) downto (max (point-min) (- pos 400)) + thereis (vui-key-at p)))) + +(defun feed-ui-open-thread-at-point () + "Open the thread of the card under point, in the native view." + (interactive) + (if-let* ((id (feed-ui--key-near (point)))) + (funcall (gethash id feed-ui--thread-actions)) + (message "feed: no post here"))) + +(defun feed-ui-reply-at-point () + "Reply to the card under point in a compose buffer." + (interactive) + (if-let* ((id (feed-ui--key-near (point)))) + (feed-ui-compose id) + (message "feed: no post here"))) + +(defun feed-ui-mute-at-point () + "Toggle muting the nick of the card under point." + (interactive) + (if-let* ((id (feed-ui--key-near (point)))) + (funcall (gethash id feed-ui--mute-actions)) + (message "feed: no post here"))) + +(defun feed-ui-sync () + "Sync subscriptions and refresh the timeline." + (interactive) + (when feed-ui--sync-action + (funcall feed-ui--sync-action))) + +(defun feed-ui-back-or-quit () + "Pop the navigation stack; quit the window at the root view." + (interactive) + (when feed-ui--back-action + (funcall feed-ui--back-action))) + +(defvar feed-ui-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "RET") #'feed-ui-open-thread-at-point) + (define-key map (kbd "r") #'feed-ui-reply-at-point) + (define-key map (kbd "m") #'feed-ui-mute-at-point) + (define-key map (kbd "g") #'feed-ui-sync) + (define-key map (kbd "q") #'feed-ui-back-or-quit) + map) + "Keymap for `feed-ui-mode'.") + +(define-derived-mode feed-ui-mode vui-mode "feed-ui" + "Major mode for the feed.org vui client." + (hl-line-mode 1)) + +(with-eval-after-load 'evil + (evil-set-initial-state 'feed-ui-mode 'normal) + (evil-define-key 'normal feed-ui-mode-map + (kbd "RET") #'feed-ui-open-thread-at-point + "r" #'feed-ui-reply-at-point + "m" #'feed-ui-mute-at-point + "g" #'feed-ui-sync + "q" #'feed-ui-back-or-quit)) + +;;;###autoload +(defun feed () + "Open the feed.org vui client." + (interactive) + (feed-ui--ensure-engine) + (let ((buf (get-buffer-create "*feed*"))) + (with-current-buffer buf + ;; Enable the derived mode BEFORE mounting so vui preserves it. + (feed-ui-mode)) + (vui-mount (vui-component 'feed-app) "*feed*") + (pop-to-buffer buf))) + +(provide 'feed-ui) +;;; feed-ui.el ends here -- cgit v1.3.1