mirror of
https://github.com/tanrax/org-social.el
synced 2026-01-08 14:13:32 +01:00
805 lines
38 KiB
EmacsLisp
805 lines
38 KiB
EmacsLisp
;;; 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
|