Files
org-social.el/ui/org-social-ui-components.el
2026-01-05 13:39:17 +01:00

805 lines
38 KiB
EmacsLisp
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;;; org-social-ui-components.el --- UI Components for Org-social -*- lexical-binding: t -*- -*- coding: utf-8 -*-
;; SPDX-License-Identifier: GPL-3.0
;; Author: Andros Fenollosa <hi@andros.dev>
;; Version: 2.10
;; URL: https://github.com/tanrax/org-social.el
;;; Commentary:
;; Reusable UI components (post, mention, group).
;;; Code:
(require 'org-social-variables)
(require 'org-social-ui-core)
(require 'org-social-ui-utils)
(require 'widget)
(require 'wid-edit)
(require 'cl-lib)
(require 'url-util)
;; Forward declarations
(declare-function org-social-relay--check-post-has-replies "org-social-relay" (post-url callback))
(declare-function org-social-ui-thread "org-social-ui-thread" (post-url))
(declare-function org-social-ui-profile "org-social-ui-profile" (user-url))
(declare-function org-social-file--new-post "org-social-file" (&optional reply-url reply-id))
(declare-function org-social-file--new-poll "org-social-file" ())
(declare-function org-social-file--edit-post "org-social-file" (timestamp))
(declare-function org-social-ui-timeline "org-social-ui-timeline" ())
(declare-function org-social-ui-notifications "org-social-ui-notifications" ())
(declare-function org-social-ui-groups "org-social-ui-groups" ())
(declare-function org-social--format-date "org-social" (timestamp))
(declare-function org-social-polls--vote-on-poll "org-social-polls" (&optional author-url timestamp))
;; Variable declarations for interactive Org content
(defvar org-social-ui--org-content-keymap)
;; Thread tracking variables
(defvar org-social-ui--posts-with-replies nil
"Hash table of post URLs that have replies according to relay.")
(defvar org-social-ui--replies-cache (make-hash-table :test 'equal)
"Cache for replies check results to avoid redundant relay queries.")
;; Helper function
(defun org-social-ui--post-has-real-replies-p (post-url)
"Check if POST-URL has real replies (excluding simple votes).
A simple vote is a reply with POLL_OPTION but no text content.
A vote with content is considered a real reply.
Uses relay synchronously and caches the result."
(require 'org-social-ui-utils)
(when (and (boundp 'org-social-relay)
org-social-relay
(not (string-empty-p org-social-relay)))
(let ((cached-result (gethash post-url org-social-ui--replies-cache)))
(if cached-result
(eq cached-result 'yes)
;; Fetch replies synchronously
(let* ((replies-data (org-social-ui--fetch-replies-sync post-url))
(has-real-replies nil))
(when replies-data
;; Check each reply to see if it's a real reply or just a simple vote
(dolist (reply-entry replies-data)
(let* ((reply-url (cdr (assoc 'post reply-entry)))
(reply-post (when reply-url
(org-social-ui--fetch-post-sync reply-url))))
(when reply-post
(let ((poll-option (alist-get 'poll_option reply-post))
(text (alist-get 'text reply-post)))
;; Consider it a real reply if:
;; 1. It has no POLL_OPTION (normal reply), OR
;; 2. It has POLL_OPTION but also has text content (vote with comment)
(when (or (not poll-option)
(and poll-option text (not (string-empty-p (string-trim text)))))
(setq has-real-replies t)))))))
;; Cache the result
(puthash post-url (if has-real-replies 'yes 'no) org-social-ui--replies-cache)
has-real-replies)))))
(defun org-social-ui--open-live-preview (author-url timestamp)
"Open live preview of post in system browser.
Constructs post URL from AUTHOR-URL and TIMESTAMP, URL-encodes it,
and opens it with `org-social-live-preview-url' base URL."
(when (and (boundp 'org-social-live-preview-url)
org-social-live-preview-url
(not (string-empty-p org-social-live-preview-url)))
(let* ((post-url (format "%s#%s" author-url timestamp))
(encoded-url (url-hexify-string post-url))
(preview-url (concat org-social-live-preview-url encoded-url)))
(browse-url preview-url))))
(defun org-social-ui--create-poll-vote (author-url timestamp poll-option)
"Create a minimal vote post for a poll.
AUTHOR-URL and TIMESTAMP identify the poll.
POLL-OPTION is the selected option."
(require 'org-social-file)
(require 'org-social-parser)
;; Determine target file (handle vfile URLs)
(let ((target-file (if (and (fboundp 'org-social-file--is-vfile-p)
(org-social-file--is-vfile-p org-social-file)
(fboundp 'org-social-file--get-local-file-path))
(org-social-file--get-local-file-path org-social-file)
org-social-file)))
;; Open social.org file (or local vfile cache)
(unless (and (buffer-file-name)
(string= (expand-file-name (buffer-file-name))
(expand-file-name target-file)))
(find-file target-file))
;; Find posts section
(goto-char (point-min))
(unless (re-search-forward "^\\* Posts" nil t)
(user-error "No '* Posts' section found in %s" target-file))
;; Go to end of buffer to insert new post
(goto-char (point-max))
;; Insert minimal vote template
(let ((vote-timestamp (org-social-parser--generate-timestamp)))
(insert "\n** \n:PROPERTIES:\n")
(insert (format ":ID: %s\n" vote-timestamp))
(insert ":CLIENT: org-social.el\n")
(insert (format ":REPLY_TO: %s#%s\n" author-url timestamp))
(insert (format ":POLL_OPTION: %s\n" poll-option))
(insert ":END:\n\n")
(save-buffer)
(message "Vote created for option: %s" poll-option))
;; Sync to vfile host if using vfile
(when (and (fboundp 'org-social-file--is-vfile-p)
(org-social-file--is-vfile-p org-social-file)
(fboundp 'org-social-file--sync-vfile))
(org-social-file--sync-vfile))
;; Move cursor to end and recenter
(goto-char (point-max))
(recenter -3)
;; Validate file after adding vote
(when (fboundp 'org-social-validator-validate-and-display)
(require 'org-social-validator)
(org-social-validator-validate-and-display))))
(defun org-social-ui--render-poll-content (text poll-end author-url timestamp _is-my-post)
"Render poll content with interactive radio buttons.
TEXT is the poll text containing question and options.
POLL-END is the poll end time.
AUTHOR-URL and TIMESTAMP identify the poll.
_IS-MY-POST indicates if this is the current user's poll (unused for now)."
(require 'org-social-polls)
(let* ((lines (split-string text "\n" t))
(question-lines '())
(options '())
(in-options nil))
;; Separate question from options
(dolist (line lines)
(let ((trimmed-line (string-trim line)))
(if (string-match "^- \\[ \\] \\(.+\\)$" trimmed-line)
(progn
(setq in-options t)
(push (string-trim (match-string 1 trimmed-line)) options))
(unless in-options
(unless (string-empty-p trimmed-line)
(push line question-lines))))))
(setq options (reverse options))
(setq question-lines (reverse question-lines))
;; Render question
(let ((org-content-start (point))
(question-text (mapconcat #'identity question-lines "\n")))
(insert question-text)
(insert "\n\n")
(let ((org-content-end (point)))
(put-text-property org-content-start org-content-end 'org-social-org-content t)
(let ((keymap-overlay (make-overlay org-content-start org-content-end)))
(overlay-put keymap-overlay 'keymap org-social-ui--org-content-keymap)
(overlay-put keymap-overlay 'priority 50)
(overlay-put keymap-overlay 'org-social-keymap-overlay t))
(org-social-ui--apply-org-mode-to-region org-content-start org-content-end)))
;; Check if poll is active
(let ((poll-active (condition-case nil
(let ((end-time (date-to-time poll-end))
(current-time (current-time)))
(time-less-p current-time end-time))
(error nil))))
(if poll-active
;; Render interactive radio buttons for active polls
(let ((selected-option-var (make-symbol "selected-option"))
(radio-widgets '()))
;; Initialize selected option
(set selected-option-var (car options))
;; Create individual radio buttons
(dolist (option options)
(let* ((is-first (equal option (car options)))
(widget (widget-create 'radio-button
:value is-first
:notify `(lambda (widget &rest _)
(when (widget-value widget)
;; Uncheck all other radio buttons
(dolist (w ',radio-widgets)
(unless (eq w widget)
(widget-value-set w nil)))
;; Update selected option
(set ',selected-option-var ,option)
(widget-setup))))))
(push widget radio-widgets)
(insert " ")
(insert (propertize option 'face 'default))
(insert "\n")))
(insert "\n")
;; Add vote button
(widget-create 'push-button
:notify `(lambda (&rest _)
(let ((selected-option (symbol-value ',selected-option-var)))
(when selected-option
(org-social-ui--create-poll-vote ,author-url ,timestamp selected-option))))
" 🗳️ Submit Vote ")
(insert "\n"))
;; For closed polls or my own polls, show as plain text
(dolist (option options)
(insert (propertize (format " • %s" option) 'face 'shadow))
(insert "\n"))
(when (not poll-active)
(insert "\n")
(insert (propertize "Poll has ended" 'face '(:foreground "#888888" :slant italic)))
(insert "\n"))))))
(defun org-social-ui--maybe-truncate-text (text)
"Truncate TEXT if it exceeds `org-social-post-preview-length'.
Returns a cons cell (DISPLAY-TEXT . FULL-TEXT).
If text is truncated, DISPLAY-TEXT ends with ellipsis, otherwise both
are the same."
(if (and org-social-post-preview-length
(> (length text) org-social-post-preview-length))
(cons (concat (substring text 0 org-social-post-preview-length) "...") text)
(cons text text)))
(defun org-social-ui--post-component (post &optional _timeline-data no-truncate)
"Insert a post component for POST with optional TIMELINE-DATA (unused).
Automatically fetches reactions from Relay if not present in POST.
When NO-TRUNCATE is non-nil, the post is displayed in full without truncation."
;; Ensure post has reactions (fetch if missing)
(let* ((author-url-temp (or (alist-get 'author-url post)
(alist-get 'url post)))
(timestamp-temp (or (alist-get 'timestamp post)
(alist-get 'id post)
(alist-get 'date post)))
(post-url-temp (when (and author-url-temp timestamp-temp)
(format "%s#%s" author-url-temp timestamp-temp)))
(post-with-reactions (if (and post-url-temp
(not (alist-get 'reactions post))
(fboundp 'org-social-ui--fetch-post-reactions-sync))
(org-social-ui--fetch-post-reactions-sync post-url-temp post)
post)))
;; Now render the post (with reactions if available)
(let* ((author (or (alist-get 'author-nick post-with-reactions)
(alist-get 'nick post-with-reactions)
"Unknown"))
(author-url (or (alist-get 'author-url post-with-reactions)
(alist-get 'url post-with-reactions)
""))
(avatar (or (alist-get 'author-avatar post-with-reactions)
(alist-get 'avatar post-with-reactions)
(alist-get 'feed-avatar post-with-reactions)))
(timestamp (or (alist-get 'timestamp post-with-reactions)
(alist-get 'id post-with-reactions)
(alist-get 'date post-with-reactions)
""))
(text (string-trim (or (alist-get 'text post-with-reactions)
(alist-get 'content post-with-reactions)
"")))
(poll-end (or (alist-get 'poll_end post-with-reactions)
(alist-get 'poll-end post-with-reactions)))
(tags (or (alist-get 'tags post-with-reactions) ""))
(mood (or (alist-get 'mood post-with-reactions) ""))
(client (alist-get 'client post-with-reactions))
(include (alist-get 'include post-with-reactions))
(boosts-data (alist-get 'boosts post-with-reactions))
(boosts-count (if boosts-data
(length (if (vectorp boosts-data)
(append boosts-data nil)
boosts-data))
0))
(my-nick (alist-get 'nick org-social-variables--my-profile))
(is-my-post (or (string= author my-nick)
(string= author-url (alist-get 'url org-social-variables--my-profile)))))
;; 1. Add line break after separator before content
(org-social-ui--insert-formatted-text "\n")
;; Calculate post URL
(let* ((post-url (if (string-empty-p author-url)
(format "%s#%s"
(alist-get 'url org-social-variables--my-profile)
timestamp)
(format "%s#%s" author-url timestamp)))
(post-data-with-url (append post `((url . ,post-url)))))
;; Create invisible widget to store post data
(widget-create 'item
:format "" ; Invisible widget
:value post-data-with-url)
;; 1.5. If this is a boost, show boost indicator and fetch original post
(when (and include (not (string-empty-p include)))
(org-social-ui--insert-formatted-text "🔄 Boosted\n\n" 1.1 "#4a90e2"))
;; 2. Post content
(when (and text (not (string-empty-p text)))
(if poll-end
;; For polls, render with interactive radio buttons
(org-social-ui--render-poll-content text poll-end author-url timestamp is-my-post)
;; For regular posts, check if text should be truncated
(let* ((truncation-result (if no-truncate
(cons text text)
(org-social-ui--maybe-truncate-text text)))
(display-text (car truncation-result))
(full-text (cdr truncation-result))
(org-content-start (point))
(formatted-text (org-social-ui--format-org-headings display-text)))
(insert formatted-text)
(insert "\n")
;; Mark region as interactive Org content
(let ((org-content-end (point)))
;; Use text properties to mark the region
(put-text-property org-content-start org-content-end
'org-social-org-content t)
(put-text-property org-content-start org-content-end
'org-social-full-text full-text)
;; Create overlay with keymap for higher priority in read-only buffers
(let ((keymap-overlay (make-overlay org-content-start org-content-end)))
(overlay-put keymap-overlay 'keymap org-social-ui--org-content-keymap)
(overlay-put keymap-overlay 'priority 50)
(overlay-put keymap-overlay 'org-social-keymap-overlay t))
;; Apply 'org-mode' syntax highlighting to this region only
(org-social-ui--apply-org-mode-to-region org-content-start org-content-end))
;; Add "Read more" button if text was truncated
(when (not (equal display-text full-text))
(org-social-ui--insert-formatted-text "\n")
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-thread ,post-url-temp))
:help-echo "Open full post in thread view"
" 📖 Read more ")
(org-social-ui--insert-formatted-text "\n")))))
;; 2.5. If this is a boost, show the original boosted post
(when (and include (not (string-empty-p include)))
;; Only show separator if boost has text/comment
(when (and text (not (string-empty-p text)))
(org-social-ui--insert-formatted-text "\n")
(org-social-ui--insert-formatted-text "── Original post ──\n" nil "#888888")
(org-social-ui--insert-formatted-text "\n"))
;; Fetch and display the original post
(let ((original-post (org-social-ui--fetch-post-sync include)))
(when original-post
(let* ((orig-author (or (alist-get 'author-nick original-post)
(alist-get 'nick original-post)
"Unknown"))
(orig-text (or (alist-get 'text original-post)
(alist-get 'content original-post)
""))
(orig-avatar (or (alist-get 'author-avatar original-post)
(alist-get 'avatar original-post)
(alist-get 'feed-avatar original-post)))
(orig-timestamp (or (alist-get 'timestamp original-post)
(alist-get 'id original-post)
(alist-get 'date original-post)
"")))
;; Show original author info
(if (and orig-avatar (not (string-empty-p orig-avatar)))
(progn
(org-social-ui--insert-formatted-text " ")
(org-social-ui--put-image-from-cache orig-avatar (line-number-at-pos) 50)
(org-social-ui--insert-formatted-text " "))
(org-social-ui--insert-formatted-text "👤 " nil "#4a90e2"))
(org-social-ui--insert-formatted-text (format "%s" orig-author) 1.1 "#4a90e2")
(org-social-ui--insert-formatted-text "")
(org-social-ui--insert-formatted-text (org-social--format-date orig-timestamp) nil "#666666")
(org-social-ui--insert-formatted-text "\n\n")
;; Show original post content
(when (and orig-text (not (string-empty-p orig-text)))
(let* ((truncation-result (if no-truncate
(cons orig-text orig-text)
(org-social-ui--maybe-truncate-text orig-text)))
(display-text (car truncation-result))
(full-text (cdr truncation-result))
(orig-content-start (point))
(formatted-text (org-social-ui--format-org-headings display-text)))
(insert formatted-text)
(insert "\n")
(let ((orig-content-end (point)))
(put-text-property orig-content-start orig-content-end
'org-social-org-content t)
(put-text-property orig-content-start orig-content-end
'org-social-full-text full-text)
(let ((keymap-overlay (make-overlay orig-content-start orig-content-end)))
(overlay-put keymap-overlay 'keymap org-social-ui--org-content-keymap)
(overlay-put keymap-overlay 'priority 50)
(overlay-put keymap-overlay 'org-social-keymap-overlay t))
(org-social-ui--apply-org-mode-to-region orig-content-start orig-content-end))
;; Add "Read more" button if text was truncated
(when (not (equal display-text full-text))
(org-social-ui--insert-formatted-text "\n")
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-thread ,include))
:help-echo "Open full post in thread view"
" 📖 Read more ")
(org-social-ui--insert-formatted-text "\n"))))
(org-social-ui--insert-formatted-text "────────────────\n" nil "#888888")
(org-social-ui--insert-formatted-text "\n")))))
;; 3. Add line break between content and tags (only if tags exist)
(when (and tags (not (string-empty-p tags)))
(org-social-ui--insert-formatted-text "\n")
;; 4. Tags only
(let ((tag-list (split-string tags "\\s-+" t)))
(dolist (tag tag-list)
(org-social-ui--insert-formatted-text (format "#%s" tag) nil org-social-hashtag-color)
(org-social-ui--insert-formatted-text " ")))
(org-social-ui--insert-formatted-text "\n"))
;; Add line break before action buttons
(insert "\n")
;; 5. Action buttons with mood at the end
(let ((first-button t))
;; Poll results button (for polls only)
(when poll-end
(widget-create 'push-button
:notify `(lambda (&rest _)
(require 'org-social-polls)
(org-social-polls--show-poll-results ,author-url ,timestamp))
:help-echo "View poll results"
" 📊 ")
(setq first-button nil))
;; Edit button (only for my posts)
(when is-my-post
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-file--edit-post ,timestamp))
:help-echo "Edit this post"
" ✏️ ")
(setq first-button nil))
;; Reply button (only for others' posts)
(when (not is-my-post)
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-file--new-post ,author-url ,timestamp))
:help-echo "Reply to this post"
"")
(setq first-button nil))
;; Thread button - show if post has reply_to OR has real replies
(let* ((reply-to (alist-get 'reply_to post))
(has-reply-to (and reply-to (not (string-empty-p reply-to))))
(has-real-replies (org-social-ui--post-has-real-replies-p post-url))
(thread-url (if has-reply-to reply-to post-url)))
(when (or has-reply-to has-real-replies)
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-thread ,thread-url))
:help-echo "View conversation thread"
" 🧵 ")
(setq first-button nil)))
;; Profile button (only for others' posts)
(when (not is-my-post)
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-profile ,author-url))
:help-echo "View user profile"
" 👤 ")
(setq first-button nil))
;; Reaction button (only for others' posts)
(when (not is-my-post)
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui--add-reaction ,author-url ,timestamp))
:help-echo "Add reaction to this post"
" 😊 ")
(setq first-button nil))
;; Boost button (only for others' posts)
(when (not is-my-post)
(unless first-button (org-social-ui--insert-formatted-text " "))
(let ((boost-label (if (> boosts-count 0)
(format " %d 🔄 " boosts-count)
" 🔄 ")))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui--boost-post ,author-url ,timestamp))
:help-echo "Boost (share) this post"
boost-label))
(setq first-button nil))
;; Share button (if org-social-live-preview-url is set)
(when (and (boundp 'org-social-live-preview-url)
org-social-live-preview-url
(not (string-empty-p org-social-live-preview-url)))
(unless first-button (org-social-ui--insert-formatted-text " "))
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui--open-live-preview ,author-url ,timestamp))
:help-echo "Share post preview link"
" 🔗 ")
(setq first-button nil))
;; Mood at the end, aligned to the right
(when (and mood (not (string-empty-p mood)))
(let* ((current-col (current-column))
(target-col 70)
(spaces-needed (max 2 (- target-col current-col))))
(org-social-ui--insert-formatted-text (make-string spaces-needed ?\s))
(org-social-ui--insert-formatted-text mood nil "#ffaa00"))))
;; 6. Display reactions if any (from Relay)
(let ((reactions-data (alist-get 'reactions post-with-reactions)))
(if (and reactions-data (> (length reactions-data) 0))
(progn
(org-social-ui--insert-formatted-text "\n\n")
(let ((first-item t)
(reactions-list (if (vectorp reactions-data)
(append reactions-data nil)
reactions-data)))
;; Show reactions
(dolist (reaction reactions-list)
(let* ((emoji (cdr (assoc 'emoji reaction)))
(posts (cdr (assoc 'posts reaction)))
(count (if (vectorp posts) (length posts) (length posts))))
(when (and emoji (> count 0))
(unless first-item
(org-social-ui--insert-formatted-text " | " nil "#888888"))
;; Show emoji and count
(org-social-ui--insert-formatted-text (format "%s %d" emoji count) nil "#ffaa00")
(setq first-item nil)))))
;; Add line break after reactions
(org-social-ui--insert-formatted-text "\n\n"))
;; No reactions, just add one line break
(org-social-ui--insert-formatted-text "\n\n")))
;; 7. Post header with avatar, author name, timestamp, and client
;; Avatar image
(if (and avatar (not (string-empty-p avatar)))
(progn
(org-social-ui--insert-formatted-text " ")
(org-social-ui--put-image-from-cache avatar (line-number-at-pos) 50)
(org-social-ui--insert-formatted-text " "))
;; No avatar - show anonymous emoji
(org-social-ui--insert-formatted-text "👤 " nil "#4a90e2"))
;; Author name
(org-social-ui--insert-formatted-text (format "%s" author) 1.1 "#4a90e2")
(org-social-ui--insert-formatted-text "")
(org-social-ui--insert-formatted-text (org-social--format-date timestamp) nil "#666666")
(when (and client (not (string-empty-p client)))
(org-social-ui--insert-formatted-text "")
(org-social-ui--insert-formatted-text client nil "#ffaa00"))
;; 8. Add line break between user info and separator
(org-social-ui--insert-formatted-text "\n")
;; 9. Final separator
(org-social-ui--insert-separator)))))
;; Declare function for fetching reactions
(declare-function org-social-ui--fetch-post-reactions-sync "org-social-ui-utils" (post-url post-data))
;;; Timeline Screen
(defun org-social-ui--insert-timeline-header ()
"Insert timeline header with navigation and actions."
(org-social-ui--insert-logo)
;; Navigation buttons
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-timeline))
:help-echo "View timeline"
" 📰 Timeline ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-notifications))
:help-echo "View notifications"
" 🔔 Notices ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-groups))
:help-echo "View groups"
" 👥 Groups ")
(org-social-ui--insert-formatted-text "\n\n")
;; Action buttons
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-file--new-post))
:help-echo "Create a new post"
" + New Post ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-file--new-poll))
:help-echo "Create a new poll"
" 📊 New Poll ")
(org-social-ui--insert-formatted-text "\n\n")
;; Help text
(org-social-ui--insert-formatted-text "Navigation: (n) Next | (p) Previous | (t) Thread | (P) Profile\n" nil "#666666")
(org-social-ui--insert-formatted-text "Post: (c) New Post | (l) New Poll | (r) Reply | (R) React | (b) Boost\n" nil "#666666")
(org-social-ui--insert-formatted-text "Actions: (N) Notices | (G) Groups\n" nil "#666666")
(org-social-ui--insert-formatted-text "Other: (q) Quit\n" nil "#666666")
(org-social-ui--insert-separator))
(defun org-social-ui--insert-timeline-posts (posts)
"Insert timeline POSTS."
(if posts
(progn
;; Store the full list for pagination
(setq org-social-ui--timeline-current-list posts)
(setq org-social-ui--current-page 1)
;; Insert first page of posts
(org-social-ui--insert-timeline-posts-paginated))
(org-social-ui--insert-formatted-text "No posts available. Check your relay configuration or followed users.\n" nil "#ff6600")))
(defun org-social-ui--insert-timeline-posts-paginated ()
"Insert the current page of timeline posts."
(when org-social-ui--timeline-current-list
(let* ((start-idx (* (- org-social-ui--current-page 1) org-social-ui--posts-per-page))
(end-idx (* org-social-ui--current-page org-social-ui--posts-per-page))
(posts-to-show (cl-subseq org-social-ui--timeline-current-list
start-idx
(min end-idx (length org-social-ui--timeline-current-list)))))
(dolist (post posts-to-show)
(org-social-ui--post-component post org-social-ui--timeline-current-list)))))
;;; Notifications Screen
(defun org-social-ui--insert-notifications-header ()
"Insert notifications header with navigation and actions."
(org-social-ui--insert-logo)
;; Navigation buttons
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-timeline))
:help-echo "View timeline"
" 📰 Timeline ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-notifications))
:help-echo "View notifications"
" 🔔 Notices ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify (lambda (&rest _) (org-social-ui-groups))
:help-echo "View groups"
" 👥 Groups ")
(org-social-ui--insert-formatted-text "\n\n")
;; Help text
(org-social-ui--insert-formatted-text "Your Mentions and Replies\n" 1.2 "#4a90e2")
(org-social-ui--insert-formatted-text "Navigation:\n" nil "#666666")
(org-social-ui--insert-formatted-text "(n) Next | (p) Previous | (T) Timeline | (G) Groups\n" nil "#666666")
(org-social-ui--insert-formatted-text "Other: (q) Quit\n" nil "#666666")
(org-social-ui--insert-separator))
(defun org-social-ui--mention-component (mention-url)
"Insert a mention component for MENTION-URL."
(org-social-ui--insert-formatted-text "📧 " 1.1 "#ff6600")
(org-social-ui--insert-formatted-text "New mention: ")
;; Extract info from URL (format: https://domain.com/social.org#timestamp)
(let ((author-url (when (string-match "\\(.*\\)#" mention-url)
(match-string 1 mention-url)))
(timestamp (when (string-match "#\\(.+\\)$" mention-url)
(match-string 1 mention-url))))
(when author-url
;; Author name button
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-profile ,author-url))
:help-echo (format "View profile: %s" author-url)
(format "@%s" (file-name-nondirectory (string-trim-right author-url "/social.org"))))
(org-social-ui--insert-formatted-text "")
(when timestamp
(org-social-ui--insert-formatted-text (org-social--format-date timestamp) nil "#666666")))
(org-social-ui--insert-formatted-text "\n ")
;; Action buttons
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-thread ,mention-url))
:help-echo "View thread"
" 🧵 View Thread ")
(org-social-ui--insert-formatted-text " ")
(when author-url
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-file--new-post ,author-url ,timestamp))
:help-echo "Reply to mention"
" ↳ Reply "))
(org-social-ui--insert-formatted-text "\n")
(org-social-ui--insert-separator)))
(defun org-social-ui--insert-notifications-content (mentions)
"Insert notifications content with MENTIONS."
(if mentions
(progn
(org-social-ui--insert-formatted-text (format "Found %d mention%s:\n\n"
(length mentions)
(if (= (length mentions) 1) "" "s"))
nil "#4a90e2")
(dolist (mention mentions)
(org-social-ui--mention-component mention)))
(org-social-ui--insert-formatted-text "No new mentions or replies found.\n" nil "#666666")
(when (and (boundp 'org-social-relay) org-social-relay (not (string-empty-p org-social-relay)))
(org-social-ui--insert-formatted-text "Make sure your relay is properly configured.\n" nil "#666666"))
(org-social-ui--insert-formatted-text "\nYour public URL: " nil "#666666")))
(defun org-social-ui--group-component (group)
"Insert a group component for GROUP (can be string or object)."
(let* ((group-name (if (stringp group)
group
(or (alist-get 'name group) "Unknown")))
(description (if (stringp group)
"Group description"
(or (alist-get 'description group) "No description")))
(member-count (if (stringp group)
0
(or (alist-get 'members group) 0)))
(post-count (if (stringp group)
0
(or (alist-get 'posts group) 0))))
;; Group header
(org-social-ui--insert-formatted-text "👥 " 1.2 "#4a90e2")
(org-social-ui--insert-formatted-text group-name 1.1 "#4a90e2")
(org-social-ui--insert-formatted-text "\n")
;; Description
(org-social-ui--insert-formatted-text (format " %s\n" description) nil "#666666")
;; Stats
(org-social-ui--insert-formatted-text " ")
(org-social-ui--insert-formatted-text (format "%d member%s"
member-count
(if (= member-count 1) "" "s"))
nil "#008000")
(org-social-ui--insert-formatted-text "")
(org-social-ui--insert-formatted-text (format "%d post%s"
post-count
(if (= post-count 1) "" "s"))
nil "#008000")
(org-social-ui--insert-formatted-text "\n\n")
;; Action buttons
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify `(lambda (&rest _)
(org-social-ui-group-posts ,group-name))
:help-echo (format "View posts in %s group" group-name)
" 📄 View Posts ")
(org-social-ui--insert-formatted-text " ")
(widget-create 'push-button
:notify `(lambda (&rest _)
(message "Joining group functionality - to be implemented"))
:help-echo (format "Join %s group" group-name)
" Join Group ")
(org-social-ui--insert-formatted-text "\n")
(org-social-ui--insert-separator)))
(provide 'org-social-ui-components)
;;; org-social-ui-components.el ends here