;;; hackernews-modern.el --- Hacker News client with modern widget UI -*- lexical-binding: t -*- ;; Copyright (C) 2012-2025 The Hackernews.el Authors ;; Author: Andros Fenollosa ;; Keywords: comm hypermedia news ;; Version: 1.0.0 ;; Package-Requires: ((emacs "28.1") (visual-fill-column "2.2")) ;; URL: https://git.andros.dev/andros/hackernews-modern-el ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; Read Hacker News from Emacs using a modern widget-based interface. ;; Fork of https://github.com/clarete/hackernews.el ;;; Code: (require 'browse-url) (require 'cus-edit) (require 'format-spec) (require 'url) (require 'json) (require 'widget) (require 'wid-edit) (require 'cl-lib) (require 'seq) ;; Forward declarations for controller symbols referenced in view. (defvar hackernews-modern-mode-map) (defvar hackernews-modern-preserve-point) (declare-function visual-fill-column-mode "visual-fill-column") ;;;; ASYNC QUEUE SYSTEM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Queue state (defvar hackernews-modern-queue--queue nil "Queue of item IDs to fetch.") (defvar hackernews-modern-queue--active-workers 0 "Number of currently active download workers.") (defvar hackernews-modern-queue--max-concurrent 5 "Maximum number of concurrent downloads. HN API is fast and reliable, so we can use more concurrent connections than typical RSS feeds.") (defvar hackernews-modern-queue--completion-callback nil "Callback to call when all items have been fetched.") (defvar hackernews-modern-queue--api-format nil "Format string for API URLs, set during initialization.") (defun hackernews-modern-queue--initialize (item-ids api-format callback) "Initialize the queue with ITEM-IDS, API-FORMAT and CALLBACK. CALLBACK will be called with a vector of item alists when complete." (setq hackernews-modern-queue--queue (mapcar (lambda (id) `((:id . ,id) (:status . :pending) (:item . nil))) item-ids)) (setq hackernews-modern-queue--api-format api-format) (setq hackernews-modern-queue--completion-callback callback) (setq hackernews-modern-queue--active-workers 0)) (defun hackernews-modern-queue--update-status (id status) "Update the status of queue item with ID to STATUS." (setq hackernews-modern-queue--queue (mapcar (lambda (item) (if (equal (alist-get :id item) id) (let ((new-item (copy-tree item))) (setcdr (assoc :status new-item) status) new-item) item)) hackernews-modern-queue--queue))) (defun hackernews-modern-queue--update-item (id item-data) "Update the item data of queue item with ID to ITEM-DATA." (setq hackernews-modern-queue--queue (mapcar (lambda (item) (if (equal (alist-get :id item) id) (let ((new-item (copy-tree item))) (setcdr (assoc :item new-item) item-data) new-item) item)) hackernews-modern-queue--queue))) (defun hackernews-modern-queue--fetch-item (id callback error-callback) "Fetch item ID asynchronously using `url-retrieve'. Calls CALLBACK with item alist on success, ERROR-CALLBACK on failure. Includes a 10-second timeout to prevent hanging downloads." (let ((timeout-timer nil) (callback-called nil) (url-buffer nil) (url (format hackernews-modern-queue--api-format (format "item/%s" id)))) (setq url-buffer (url-retrieve url (lambda (status) ;; Cancel timeout timer if it exists (when timeout-timer (cancel-timer timeout-timer)) ;; Only execute callback once (unless callback-called (setq callback-called t) (let ((result nil)) (condition-case err (progn ;; Check for errors first (when (plist-get status :error) (error "Download failed: %S" (plist-get status :error))) ;; Check HTTP status (goto-char (point-min)) (if (re-search-forward "^HTTP/[0-9]\\.[0-9] \\([0-9]\\{3\\}\\)" nil t) (let ((status-code (string-to-number (match-string 1)))) (if (and (>= status-code 200) (< status-code 300)) (progn ;; Success - extract JSON content (goto-char (point-min)) (when (re-search-forward "\r\n\r\n\\|\n\n" nil t) (setq result (json-parse-buffer :object-type 'alist)))) ;; HTTP error (message "HTTP %d error fetching item %s" status-code id) (setq result nil))) ;; No HTTP status found (message "Invalid HTTP response for item %s" id) (setq result nil))) (error (message "Error fetching item %s: %s" id (error-message-string err)) (setq result nil))) ;; Kill buffer to avoid accumulation (kill-buffer (current-buffer)) ;; Call appropriate callback (if result (funcall callback result) (funcall error-callback))))) nil t)) ;; Set up timeout timer (10 seconds for HN API) (setq timeout-timer (run-at-time 10 nil (lambda () (unless callback-called (setq callback-called t) (message "Timeout fetching item %s (10 seconds)" id) ;; Kill the url-retrieve buffer if it exists (when (and url-buffer (buffer-live-p url-buffer)) ;; First kill the process to avoid interactive prompt (let ((proc (get-buffer-process url-buffer))) (when (and proc (process-live-p proc)) (delete-process proc))) ;; Now kill the buffer safely (kill-buffer url-buffer)) (funcall error-callback))))))) (defun hackernews-modern-queue--process-next-pending () "Process the next pending item in the queue if worker slots available." (when (< hackernews-modern-queue--active-workers hackernews-modern-queue--max-concurrent) (let ((pending-item (seq-find (lambda (item) (eq (alist-get :status item) :pending)) hackernews-modern-queue--queue))) (when pending-item (let ((id (alist-get :id pending-item))) ;; Mark as processing and increment active workers (hackernews-modern-queue--update-status id :processing) (setq hackernews-modern-queue--active-workers (1+ hackernews-modern-queue--active-workers)) ;; Start the download (hackernews-modern-queue--fetch-item id ;; Success callback (lambda (item-data) (hackernews-modern-queue--update-status id :done) (hackernews-modern-queue--update-item id item-data) (setq hackernews-modern-queue--active-workers (1- hackernews-modern-queue--active-workers)) ;; Process next pending item with small delay to avoid overwhelming API (run-at-time 0.05 nil #'hackernews-modern-queue--process-next-pending) (hackernews-modern-queue--check-completion)) ;; Error callback (lambda () (hackernews-modern-queue--update-status id :error) (setq hackernews-modern-queue--active-workers (1- hackernews-modern-queue--active-workers)) ;; Process next pending item with small delay (run-at-time 0.05 nil #'hackernews-modern-queue--process-next-pending) (hackernews-modern-queue--check-completion)))))))) (defun hackernews-modern-queue--process () "Process the queue asynchronously with limited concurrency." ;; Reset active workers counter (setq hackernews-modern-queue--active-workers 0) ;; Launch initial batch (up to max concurrent) with staggered start ;; HN API is fast, so we use shorter delays (0.05s between launches) (dotimes (i hackernews-modern-queue--max-concurrent) (run-at-time (* i 0.05) nil #'hackernews-modern-queue--process-next-pending))) (defun hackernews-modern-queue--check-completion () "Check if the download queue is complete and call callback if done." (let* ((total (length hackernews-modern-queue--queue)) (done (length (seq-filter (lambda (i) (eq (alist-get :status i) :done)) hackernews-modern-queue--queue))) (failed (length (seq-filter (lambda (i) (eq (alist-get :status i) :error)) hackernews-modern-queue--queue))) (in-progress (seq-filter (lambda (i) (or (eq (alist-get :status i) :processing) (eq (alist-get :status i) :pending))) hackernews-modern-queue--queue))) ;; Show progress for longer downloads (when (and (> total 10) (> (length in-progress) 0)) (message "Loading items... %d/%d completed%s" done total (if (> failed 0) (format " (%d failed)" failed) ""))) (when (= (length in-progress) 0) ;; All downloads complete - collect results in order (let ((items (make-vector total nil)) (index 0)) ;; Fill vector with results, maintaining original order ;; Use :null for failed items (will be filtered later) (dolist (queue-item hackernews-modern-queue--queue) (aset items index (if (eq (alist-get :status queue-item) :done) (alist-get :item queue-item) :null)) (setq index (1+ index))) ;; Final status message (if (> failed 0) (message "Loaded %d items (%d failed)" done failed) (message "Loaded %d items" done)) ;; Call completion callback (when hackernews-modern-queue--completion-callback (funcall hackernews-modern-queue--completion-callback items)))))) (defun hackernews-modern-queue-fetch-items (item-ids api-format callback) "Fetch items with ITEM-IDS asynchronously and call CALLBACK with results. API-FORMAT is the format string for constructing API URLs. CALLBACK will be called with a vector of item alists. Each item alist has the HN API structure with keys like: id, title, url, score, by, descendants, etc. Failed items will be represented as :null in the result vector. Returns immediately and processes items in parallel." (if (null item-ids) (progn (message "No item IDs provided") (funcall callback (make-vector 0 nil))) (let ((n (length item-ids))) (message "Fetching %d item%s..." n (if (> n 1) "s" "")) (hackernews-modern-queue--initialize item-ids api-format callback) (hackernews-modern-queue--process)))) ;;;; MODEL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;; Customization (defgroup hackernews-modern nil "Hacker News client with modern widget UI." :group 'external :prefix "hackernews-modern-") (defcustom hackernews-modern-items-per-page 20 "Default number of stories to retrieve in one go." :package-version '(hackernews-modern . "0.4.0") :type 'integer) (defcustom hackernews-modern-default-feed "top" "Default story feed to load. See `hackernews-modern-feed-names' for supported feed types." :package-version '(hackernews-modern . "0.4.0") :type '(choice (const :tag "Top stories" "top") (const :tag "New stories" "new") (const :tag "Best stories" "best") (const :tag "Ask stories" "ask") (const :tag "Show stories" "show") (const :tag "Job stories" "job"))) (defcustom hackernews-modern-suppress-url-status t "Whether to suppress messages controlled by `url-show-status'. When nil, `url-show-status' determines whether certain status messages are displayed when retrieving online data. This is suppressed by default so that the hackernews-modern progress reporter is not interrupted." :package-version '(hackernews-modern . "0.4.0") :type 'boolean) ;;;;; Constants (defconst hackernews-modern-api-version "v0" "Currently supported version of the Hacker News API.") (defconst hackernews-modern-api-format (format "https://hacker-news.firebaseio.com/%s/%%s.json" hackernews-modern-api-version) "Format of targeted Hacker News API URLs.") (defconst hackernews-modern-site-item-format "https://news.ycombinator.com/item?id=%s" "Format of Hacker News website item URLs.") (defvar hackernews-modern-feed-names '(("top" . "top stories") ("new" . "new stories") ("best" . "best stories") ("ask" . "ask stories") ("show" . "show stories") ("job" . "job stories")) "Map feed types as strings to their display names.") (put 'hackernews-modern-feed-names 'risky-local-variable t) (defvar hackernews-modern-feed-history () "Completion history of hackernews-modern feeds switched to.") ;;;;; Buffer-local state (defvar-local hackernews-modern--feed-state () "Plist capturing state of current buffer's Hacker News feed. :feed - Type of endpoint feed; see `hackernews-modern-feed-names'. :items - Vector holding items being or last fetched. :register - Cons of number of items currently displayed and vector of item IDs last read from this feed. The `car' is thus an offset into the `cdr'.") (defun hackernews-modern--get (prop) "Extract value of PROP from `hackernews-modern--feed-state'." (plist-get hackernews-modern--feed-state prop)) (defun hackernews-modern--put (prop val) "Change value in `hackernews-modern--feed-state' of PROP to VAL." (setq hackernews-modern--feed-state (plist-put hackernews-modern--feed-state prop val))) ;;;;; URL helpers (defun hackernews-modern--comments-url (id) "Return Hacker News website URL for item with ID." (format hackernews-modern-site-item-format id)) (defun hackernews-modern--format-api-url (fmt &rest args) "Construct a Hacker News API URL. The result of passing FMT and ARGS to `format' is substituted in `hackernews-modern-api-format'." (format hackernews-modern-api-format (apply #'format fmt args))) (defun hackernews-modern--item-url (id) "Return Hacker News API URL for item with ID." (hackernews-modern--format-api-url "item/%s" id)) (defun hackernews-modern--feed-url (feed) "Return Hacker News API URL for FEED. See `hackernews-modern-feed-names' for supported values of FEED." (hackernews-modern--format-api-url "%sstories" feed)) (defun hackernews-modern--feed-name (feed) "Lookup FEED in `hackernews-modern-feed-names'." (cdr (assoc-string feed hackernews-modern-feed-names))) (defun hackernews-modern--feed-annotation (feed) "Annotate FEED during completion. This is intended as an :annotation-function in `completion-extra-properties'." (let ((name (hackernews-modern--feed-name feed))) (and name (concat " - " name)))) ;;;;; HTTP and JSON (Asynchronous) (defun hackernews-modern--retrieve-items-async (callback) "Retrieve items associated with current buffer asynchronously. Calls CALLBACK when all items have been fetched. Uses parallel queue system for non-blocking, fast downloads." (let* ((target-buffer (current-buffer)) (reg (hackernews-modern--get :register)) (nitem (hackernews-modern--get :nitem)) (offset (car reg)) (ids (cdr reg)) (item-ids '())) ;; Build list of item IDs to fetch (dotimes (i nitem) (push (aref ids (+ offset i)) item-ids)) (setq item-ids (nreverse item-ids)) ;; Fetch items asynchronously using queue system (hackernews-modern-queue-fetch-items item-ids hackernews-modern-api-format (lambda (items) ;; Execute in the correct buffer context (with-current-buffer target-buffer ;; Store items in buffer state (hackernews-modern--put :items items) ;; Call completion callback (funcall callback)))))) ;;;; VIEW ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;; Faces (defface hackernews-modern-link '((t :inherit link :underline nil)) "Face used for story title links." :package-version '(hackernews-modern . "0.4.0")) (defface hackernews-modern-comment-count '((t :inherit hackernews-modern-link)) "Face used for comment counts." :package-version '(hackernews-modern . "0.4.0")) (defface hackernews-modern-score '((t :inherit default)) "Face used for the score of a story." :package-version '(hackernews-modern . "0.4.0")) (defface hackernews-modern-logo '((t :foreground "#ff6600" :height 1.5)) "Face used for the \"Y\" in the Hacker News logo." :package-version '(hackernews-modern . "0.8.0")) (defface hackernews-modern-title-text '((t :foreground "#ff6600" :height 1.3)) "Face used for the \"Hacker News\" title text." :package-version '(hackernews-modern . "0.8.0")) (defface hackernews-modern-separator '((t :foreground "#666666")) "Face used for horizontal separator lines." :package-version '(hackernews-modern . "0.8.0")) (defface hackernews-modern-score-modern '((t :foreground "#ff6600")) "Face used for story scores." :package-version '(hackernews-modern . "0.8.0")) (defface hackernews-modern-author '((t :foreground "#0066cc")) "Face used for author names." :package-version '(hackernews-modern . "0.8.0")) (defface hackernews-modern-feed-indicator '((t :foreground "#ff6600")) "Face used for the current feed indicator." :package-version '(hackernews-modern . "0.8.0")) ;;;;; Customization (visual) (defcustom hackernews-modern-display-width 80 "Maximum width for displaying hackernews-modern content." :package-version '(hackernews-modern . "0.8.0") :type 'integer) (defcustom hackernews-modern-enable-emojis nil "Whether to display emojis in the interface. When non-nil, feed navigation buttons and comment counts will include emoji icons for visual enhancement." :package-version '(hackernews-modern . "0.8.0") :type 'boolean) (defcustom hackernews-modern-before-render-hook () "Hook called before rendering any new items." :package-version '(hackernews-modern . "0.4.0") :type 'hook) (defcustom hackernews-modern-after-render-hook () "Hook called after rendering any new items. The position of point will not have been affected by the render." :package-version '(hackernews-modern . "0.4.0") :type 'hook) (defcustom hackernews-modern-finalize-hook () "Hook called as final step of loading any new items. The position of point may have been adjusted after the render, buffer-local feed state will have been updated and the hackernews-modern buffer will be current and displayed in the selected window." :package-version '(hackernews-modern . "0.4.0") :type 'hook) ;;;;; UI helpers (defconst hackernews-modern--separator-char ?- "Character used for horizontal separators.") (defun hackernews-modern--string-separator () "Return a separator string of `hackernews-modern-display-width' dashes." (make-string hackernews-modern-display-width hackernews-modern--separator-char)) (defun hackernews-modern--insert-separator () "Insert a horizontal separator line." (insert "\n") (insert (propertize (hackernews-modern--string-separator) 'face 'hackernews-modern-separator)) (insert "\n\n")) (defun hackernews-modern--insert-logo () "Insert the Hacker News logo." (insert "\n") (insert (propertize "Y " 'face 'hackernews-modern-logo)) (insert (propertize "Hacker News" 'face 'hackernews-modern-title-text)) (insert "\n\n")) (defconst hackernews-modern--feed-buttons '(("top" "🔥 " "View top stories" hackernews-modern-top-stories) ("new" "🆕 " "View new stories" hackernews-modern-new-stories) ("best" "⭐ " "View best stories" hackernews-modern-best-stories) ("ask" "❓ " "View ask stories" hackernews-modern-ask-stories) ("show" "📺 " "View show stories" hackernews-modern-show-stories)) "Feed button specs: (feed-key emoji help-text command).") (defun hackernews-modern--insert-header (feed-name) "Insert the page header showing FEED-NAME and navigation buttons." (hackernews-modern--insert-logo) (dolist (spec hackernews-modern--feed-buttons) (let ((label (capitalize (nth 0 spec))) (emoji (nth 1 spec)) (help (nth 2 spec)) (cmd (nth 3 spec))) (widget-create 'push-button :notify (lambda (&rest _) (call-interactively cmd)) :help-echo help (format " %s%s " (if hackernews-modern-enable-emojis emoji "") label)) (insert " "))) (widget-create 'push-button :notify (lambda (&rest _) (hackernews-modern-reload)) :help-echo "Refresh current feed" " ↻ Refresh ") (insert "\n\n") (insert (propertize (format "Showing: %s\n" feed-name) 'face 'hackernews-modern-feed-indicator)) (insert "Keyboard: (n) Next | (p) Previous | (g) Refresh | (q) Quit\n") (hackernews-modern--insert-separator)) ;;;;; Item rendering (autoload 'xml-substitute-special "xml") (defun hackernews-modern--render-item (item) "Render Hacker News ITEM in the current buffer using widgets." (let* ((id (cdr (assq 'id item))) (title (cdr (assq 'title item))) (score (cdr (assq 'score item))) (by (cdr (assq 'by item))) (item-url (cdr (assq 'url item))) (descendants (cdr (assq 'descendants item))) (comments-url (hackernews-modern--comments-url id)) (item-start (point))) (setq title (xml-substitute-special title)) (widget-create 'push-button :notify (lambda (&rest _) (browse-url (or item-url comments-url))) :help-echo (or item-url "No URL") :format "%[%v%]" title) (insert "\n") (insert (propertize " " 'face 'default)) (insert (propertize (format "↑%d" (or score 0)) 'face 'hackernews-modern-score-modern)) (insert " | ") (widget-create 'push-button :notify (lambda (&rest _) (browse-url comments-url)) :help-echo (format "View comments: %s" comments-url) :format "%[%v%]" (format "%s%d comment%s" (if hackernews-modern-enable-emojis "💬 " "") (or descendants 0) (if (= (or descendants 0) 1) "" "s"))) (when by (insert " | by ") (insert (propertize by 'face 'hackernews-modern-author))) (insert "\n") (hackernews-modern--insert-separator) (put-text-property item-start (point) 'hackernews-modern-item-id id))) ;;;;; Buffer display (defun hackernews-modern--display-items () "Render items associated with the current buffer and display it." (let* ((reg (hackernews-modern--get :register)) (items (hackernews-modern--get :items)) (nitem (length items)) (feed (hackernews-modern--get :feed)) (feed-name (hackernews-modern--feed-name feed)) (first-load (= (buffer-size) 0)) (inhibit-read-only t)) (when first-load (hackernews-modern--insert-header feed-name)) (run-hooks 'hackernews-modern-before-render-hook) (save-excursion (goto-char (point-max)) (mapc #'hackernews-modern--render-item (cl-remove-if (lambda (item) (or (eq item :null) (cdr (assq 'deleted item)) (cdr (assq 'dead item)))) items))) (run-hooks 'hackernews-modern-after-render-hook) (use-local-map (make-composed-keymap (list widget-keymap hackernews-modern-mode-map) special-mode-map)) (widget-setup) (when (and (require 'visual-fill-column nil t) (boundp 'visual-fill-column-width)) (setq-local visual-fill-column-width hackernews-modern-display-width) (setq-local visual-fill-column-center-text t) (visual-fill-column-mode 1)) (when (fboundp 'display-line-numbers-mode) (display-line-numbers-mode 0)) (cond (first-load (goto-char (point-min)) (widget-forward 1)) ((not (or (<= nitem 0) hackernews-modern-preserve-point)) (goto-char (point-max)) (hackernews-modern-previous-item nitem))) (setcar reg (+ (car reg) nitem))) (read-only-mode 1) (pop-to-buffer (current-buffer) '((display-buffer-same-window))) (run-hooks 'hackernews-modern-finalize-hook)) ;;;; CONTROLLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;; Customization (behavioral) (defcustom hackernews-modern-preserve-point t "Whether to preserve point when loading more stories. When nil, point is placed on first new item retrieved." :package-version '(hackernews-modern . "0.4.0") :type 'boolean) (defcustom hackernews-modern-internal-browser-function (if (functionp 'eww-browse-url) #'eww-browse-url #'browse-url-text-emacs) "Function to load a given URL within Emacs. See `browse-url-browser-function' for some possible options." :package-version '(hackernews-modern . "0.4.0") :type (cons 'radio (butlast (cdr (custom-variable-type 'browse-url-browser-function))))) ;;;;; Keymaps (defvar hackernews-modern-mode-map (let ((map (make-sparse-keymap))) (define-key map "f" #'hackernews-modern-switch-feed) (define-key map "g" #'hackernews-modern-reload) (define-key map "m" #'hackernews-modern-load-more-stories) (define-key map "n" #'hackernews-modern-next-item) (define-key map "p" #'hackernews-modern-previous-item) map) "Keymap used in hackernews-modern buffer.") ;;;;; Major mode (define-derived-mode hackernews-modern-mode special-mode "HN" "Mode for browsing Hacker News. Key bindings: \\ \\[hackernews-modern-next-item] Move to next story. \\[hackernews-modern-previous-item] Move to previous story. \\[hackernews-modern-load-more-stories] Load more stories. \\[hackernews-modern-reload] Reload stories. \\[hackernews-modern-switch-feed] Switch feed. \\\\[quit-window] Quit. \\{hackernews-modern-mode-map}" :interactive nil (setq hackernews-modern--feed-state ()) (setq truncate-lines t) (buffer-disable-undo)) ;;;;; Navigation (defun hackernews-modern-next-item (&optional n) "Move to Nth next story (previous if N is negative). N defaults to 1." (declare (modes hackernews-modern-mode)) (interactive "p") (let ((count (or n 1)) (separator-regex (concat "^" (regexp-quote (hackernews-modern--string-separator)) "$"))) (if (< count 0) (hackernews-modern-previous-item (- count)) (dotimes (_ count) (if (search-forward-regexp separator-regex nil t) (progn (forward-line 2) (beginning-of-line) (recenter)) (message "No more stories")))))) (defun hackernews-modern-previous-item (&optional n) "Move to Nth previous story (next if N is negative). N defaults to 1." (declare (modes hackernews-modern-mode)) (interactive "p") (let ((count (or n 1)) (separator-regex (concat "^" (regexp-quote (hackernews-modern--string-separator)) "$"))) (if (< count 0) (hackernews-modern-next-item (- count)) (dotimes (_ count) (search-backward-regexp separator-regex nil t) (if (search-backward-regexp separator-regex nil t) (progn (forward-line 2) (beginning-of-line) (recenter)) (goto-char (point-min)) (widget-forward 1)))))) (defun hackernews-modern-first-item () "Move point to the first story in the hackernews-modern buffer." (declare (modes hackernews-modern-mode)) (interactive) (goto-char (point-min)) (hackernews-modern-next-item)) ;;;;; Orchestration (defun hackernews-modern--fetch-feed-ids (feed callback) "Fetch list of item IDs from FEED asynchronously. Calls CALLBACK with the vector of IDs." (let ((url (hackernews-modern--feed-url feed))) (url-retrieve url (lambda (status) (let ((ids nil)) (condition-case err (progn (when (plist-get status :error) (error "Failed to fetch feed: %S" (plist-get status :error))) (goto-char (point-min)) (re-search-forward "\r\n\r\n\\|\n\n" nil t) (setq ids (json-parse-buffer))) (error (message "Error fetching feed list: %s" (error-message-string err)) (setq ids (make-vector 0 nil)))) (kill-buffer (current-buffer)) (funcall callback ids))) nil t))) (defun hackernews-modern--load-stories (feed n &optional append) "Retrieve and render at most N items from FEED asynchronously. Create and setup corresponding hackernews-modern buffer if necessary. If APPEND is nil, refresh the list of items from FEED and render at most N of its top items. Any previous hackernews-modern buffer contents are overwritten. Otherwise, APPEND should be a cons cell (OFFSET . IDS), where IDS is the vector of item IDs corresponding to FEED and OFFSET indicates where in IDS the previous retrieval and render left off. At most N of FEED's items starting at OFFSET are then rendered at the end of the hackernews-modern buffer. This function returns immediately; items are loaded asynchronously and the buffer is updated when ready." (let* ((name (hackernews-modern--feed-name feed)) (offset (or (car append) 0))) (if append ;; Appending to existing feed - IDs already available (let ((ids (cdr append))) (with-current-buffer (get-buffer-create (format "*hackernews-modern %s*" name)) (hackernews-modern--put :feed feed) (hackernews-modern--put :register (cons offset ids)) (hackernews-modern--put :nitem (max 0 (min (- (length ids) offset) (prefix-numeric-value (or n hackernews-modern-items-per-page))))) ;; Fetch items asynchronously (hackernews-modern--retrieve-items-async (lambda () (hackernews-modern--display-items))))) ;; Fresh load - need to fetch feed IDs first (message "Retrieving %s..." name) (hackernews-modern--fetch-feed-ids feed (lambda (ids) (with-current-buffer (get-buffer-create (format "*hackernews-modern %s*" name)) (let ((inhibit-read-only t)) (erase-buffer)) (remove-overlays) (hackernews-modern-mode) (hackernews-modern--put :feed feed) (hackernews-modern--put :register (cons offset ids)) (hackernews-modern--put :nitem (max 0 (min (- (length ids) offset) (prefix-numeric-value (or n hackernews-modern-items-per-page))))) ;; Fetch items asynchronously (hackernews-modern--retrieve-items-async (lambda () (hackernews-modern--display-items))))))))) ;;;;; Interactive commands ;;;###autoload (defun hackernews-modern (&optional n) "Read top N Hacker News stories. The feed is determined by `hackernews-modern-default-feed' and N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories hackernews-modern-default-feed n)) (defun hackernews-modern-reload (&optional n) "Reload top N stories from the current feed. N defaults to `hackernews-modern-items-per-page'." (declare (modes hackernews-modern-mode)) (interactive "P") (unless (derived-mode-p #'hackernews-modern-mode) (user-error "Not a hackernews-modern buffer")) (hackernews-modern--load-stories (or (hackernews-modern--get :feed) (user-error "Buffer unassociated with feed")) n)) (defun hackernews-modern-load-more-stories (&optional n) "Load N more stories into the hackernews-modern buffer. N defaults to `hackernews-modern-items-per-page'." (declare (modes hackernews-modern-mode)) (interactive "P") (unless (derived-mode-p #'hackernews-modern-mode) (user-error "Not a hackernews-modern buffer")) (let ((feed (hackernews-modern--get :feed)) (reg (hackernews-modern--get :register))) (unless (and feed reg) (user-error "Buffer in invalid state")) (if (>= (car reg) (length (cdr reg))) (message "%s" (substitute-command-keys "\ End of feed; type \\[hackernews-modern-reload] to load new items.")) (hackernews-modern--load-stories feed n reg)))) (defun hackernews-modern-switch-feed (&optional n) "Read top N stories from a feed chosen with completion. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories (let ((completion-extra-properties (list :annotation-function #'hackernews-modern--feed-annotation))) (completing-read (format-prompt "Hacker News feed" hackernews-modern-default-feed) hackernews-modern-feed-names nil t nil 'hackernews-modern-feed-history hackernews-modern-default-feed)) n)) (defun hackernews-modern-top-stories (&optional n) "Read top N Hacker News top stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "top" n)) (defun hackernews-modern-new-stories (&optional n) "Read top N Hacker News new stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "new" n)) (defun hackernews-modern-best-stories (&optional n) "Read top N Hacker News best stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "best" n)) (defun hackernews-modern-ask-stories (&optional n) "Read top N Hacker News ask stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "ask" n)) (defun hackernews-modern-show-stories (&optional n) "Read top N Hacker News show stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "show" n)) (defun hackernews-modern-job-stories (&optional n) "Read top N Hacker News job stories. N defaults to `hackernews-modern-items-per-page'." (interactive "P") (hackernews-modern--load-stories "job" n)) (provide 'hackernews-modern) ;;; hackernews-modern.el ends here