branch: elpa/jabber
commit 4b2ac96c397939d36a6e585508d19f95c209b8f8
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>

    chat: Auto-display images only for roster contacts by default
---
 lisp/jabber-chat.el       | 165 +++++++++++++++++++++++++++++++---------------
 tests/jabber-test-chat.el | 137 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 249 insertions(+), 53 deletions(-)

diff --git a/lisp/jabber-chat.el b/lisp/jabber-chat.el
index 5c52857496..5e8ee4f456 100644
--- a/lisp/jabber-chat.el
+++ b/lisp/jabber-chat.el
@@ -119,9 +119,26 @@ formatted according to this string, is different to the 
last
 rare time printed."
   :type 'string)
 
-(defcustom jabber-chat-display-images t
-  "When non-nil, fetch and display image URLs inline in chat buffers."
-  :type 'boolean)
+(defcustom jabber-chat-display-images 'roster
+  "When to automatically fetch and display image URLs inline.
+t means auto-display in every chat buffer.
+nil means never fetch automatically.
+The symbol `roster' means auto-display only in one-to-one chats
+whose peer is on the roster; in MUC buffers and chats with
+unknown peers, image URLs stay clickable.
+
+Automatic display is further limited to the image types in
+`jabber-chat-image-auto-types' and the size limit in
+`jabber-image-max-bytes'."
+  :type '(choice (const :tag "All chat buffers" t)
+                 (const :tag "Roster contacts only" roster)
+                 (const :tag "Never" nil)))
+
+(defcustom jabber-chat-image-auto-types '(png jpeg gif webp)
+  "Image types eligible for automatic inline display.
+The type is detected from the downloaded bytes, never from the
+URL.  Images of other types stay clickable URLs."
+  :type '(repeat (symbol :tag "Image type")))
 
 (defface jabber-rare-time-face
   '((t :inherit font-lock-comment-face :underline t))
@@ -1881,10 +1898,6 @@ N is passed to `self-insert-command' when point is not 
on an inline image."
          'keymap jabber-chat-url-keymap
          'rear-nonsticky t)))
 
-(defun jabber-chat--image-fetching-p (beg url)
-  "Return non-nil when BEG is already marked as fetching URL."
-  (equal (get-text-property beg 'jabber-chat-image-fetching) url))
-
 (defun jabber-chat--mark-image-fetching (beg end url)
   "Mark URL text from BEG to END as an image fetch attempt."
   (add-text-properties
@@ -1893,22 +1906,31 @@ N is passed to `self-insert-command' when point is not 
on an inline image."
          'jabber-chat-image-fetching url))
   (jabber-chat--add-url-keymap beg end))
 
+(defun jabber-chat--url-markers-valid-p (beg end url buffer)
+  "Return non-nil when markers BEG and END still delimit URL in BUFFER.
+Must be called with BUFFER current."
+  (and (markerp beg) (markerp end)
+       (eq (marker-buffer beg) buffer)
+       (eq (marker-buffer end) buffer)
+       (marker-position beg) (marker-position end)
+       (<= beg end)
+       (equal (buffer-substring-no-properties beg end) url)))
+
 (defun jabber-chat--replace-url-with-image (image url beg end buffer)
   "Display fetched IMAGE over URL text between markers BEG and END.
-Cache IMAGE by URL.  Leave URL text visible when IMAGE is nil,
-BUFFER is dead, or the marker range no longer contains URL."
+Cache IMAGE by URL.  When IMAGE is nil, mark the URL text in
+BUFFER as a failed fetch so the automatic scan does not retry it."
   (when image
-    (jabber-chat--cache-image url image)
-    (when (buffer-live-p buffer)
-      (with-current-buffer buffer
-        (when (and (markerp beg) (markerp end)
-                   (eq (marker-buffer beg) buffer)
-                   (eq (marker-buffer end) buffer)
-                   (marker-position beg) (marker-position end)
-                   (<= beg end)
-                   (equal (buffer-substring-no-properties beg end) url))
-          (let ((inhibit-read-only t))
-            (jabber-chat--apply-image-display image beg end url)))))))
+    (jabber-chat--cache-image url image))
+  (when (buffer-live-p buffer)
+    (with-current-buffer buffer
+      (when (jabber-chat--url-markers-valid-p beg end url buffer)
+        (let ((inhibit-read-only t))
+          (cond (image
+                 (jabber-chat--apply-image-display image beg end url))
+                ((not (jabber-chat--image-displayed-p beg url))
+                 (put-text-property beg end 'jabber-chat-image-fetching
+                                    'failed))))))))
 
 (defvar-local jabber-chat--image-scan-timer nil
   "Idle timer for scanning image URLs in this buffer.")
@@ -1934,45 +1956,82 @@ Added to `jabber-chat-printers' to trigger after each 
message."
           "\\(?:[?#][^ \t\n<>\"]*\\)?")
   "Regexp matching HTTP(S) and aesgcm:// image URLs.")
 
+(defun jabber-chat--auto-display-images-p ()
+  "Return non-nil when this buffer may auto-display inline images.
+Implements `jabber-chat-display-images': nil means never,
+`roster' means only one-to-one chats with roster contacts, any
+other non-nil value means always."
+  (pcase jabber-chat-display-images
+    ('nil nil)
+    ('roster (and jabber-chatting-with
+                  (jabber-roster-contact-p jabber-buffer-connection
+                                           jabber-chatting-with)))
+    (_ t)))
+
+(defun jabber-chat--image-fetch-state (beg)
+  "Return the image fetch state at BEG.
+nil when never attempted, the URL string while a fetch is in
+flight, or the symbol `failed' after an unsuccessful attempt."
+  (get-text-property beg 'jabber-chat-image-fetching))
+
+(defun jabber-chat--isolate-image-url (beg end)
+  "Ensure the URL between BEG and END starts on its own line.
+Return the possibly shifted bounds as a cons cell."
+  (cond ((or (= beg (point-min))
+             (eq (char-before beg) ?\n))
+         (cons beg end))
+        (t
+         (save-excursion
+           (goto-char beg)
+           (insert "\n"))
+         (cons (1+ beg) (1+ end)))))
+
+(defun jabber-chat--start-image-fetch (url beg end allowed-types)
+  "Fetch image URL and display it over BEG..END in the current buffer.
+ALLOWED-TYPES restricts decoding per `jabber-image-from-data';
+nil permits any supported type."
+  (jabber-chat--mark-image-fetching beg end url)
+  (let ((beg (copy-marker beg))
+        (end (copy-marker end))
+        (buf (current-buffer)))
+    (if (string-prefix-p "aesgcm://" url)
+        (jabber-chat--fetch-aesgcm-image
+         url allowed-types
+         #'jabber-chat--replace-url-with-image url beg end buf)
+      (jabber-image-fetch
+       url allowed-types
+       #'jabber-chat--replace-url-with-image url beg end buf))))
+
+(defun jabber-chat--scan-image-url (url beg end auto)
+  "Process one image URL between BEG and END found by the scan.
+Mark it clickable, restore a cached image when available, and
+when AUTO is non-nil start a fetch restricted to
+`jabber-chat-image-auto-types'."
+  (jabber-chat--add-url-keymap beg end)
+  (put-text-property beg end 'jabber-chat-image-url url)
+  (unless (jabber-chat--image-displayed-p beg url)
+    (unless (jabber-chat--restore-cached-image url beg end)
+      (when (and auto (null (jabber-chat--image-fetch-state beg)))
+        (jabber-chat--start-image-fetch
+         url beg end jabber-chat-image-auto-types)))))
+
 (defun jabber-chat-display-buffer-images ()
-  "Scan buffer for image URLs and display them inline.
-Preserve URL text and restore cached images without refetching."
+  "Scan buffer for image URLs and display permitted ones inline.
+URL text is preserved; images that `jabber-chat-display-images'
+does not auto-display stay clickable and load with RET."
   (interactive)
   (save-excursion
     (let ((inhibit-read-only t)
-          (limit (and (markerp jabber-point-insert) jabber-point-insert)))
-      (when (and jabber-chat-display-images (display-graphic-p))
+          (limit (and (markerp jabber-point-insert) jabber-point-insert))
+          (auto (jabber-chat--auto-display-images-p)))
+      (when (display-graphic-p)
         (goto-char (point-min))
         (while (re-search-forward jabber-chat--image-url-re limit t)
-          (let ((url (match-string-no-properties 0))
-                (url-beg (match-beginning 0))
-                (url-end (match-end 0)))
-            ;; Insert newline before URL so images appear on their
-            ;; own line.
-            (when (and (> url-beg (point-min))
-                       (not (eq (char-before url-beg) ?\n)))
-              (save-excursion
-                (goto-char url-beg)
-                (insert "\n"))
-              (setq url-beg (1+ url-beg)
-                    url-end (1+ url-end)))
-            (jabber-chat--add-url-keymap url-beg url-end)
-            (unless (jabber-chat--image-displayed-p url-beg url)
-              (unless (jabber-chat--restore-cached-image url url-beg url-end)
-                (unless (jabber-chat--image-fetching-p url-beg url)
-                  (jabber-chat--mark-image-fetching url-beg url-end url)
-                  (let ((beg (copy-marker url-beg))
-                        (end (copy-marker url-end))
-                        (buf (current-buffer)))
-                    (if (string-prefix-p "aesgcm://" url)
-                        (jabber-chat--fetch-aesgcm-image
-                         url nil
-                         #'jabber-chat--replace-url-with-image
-                         url beg end buf)
-                      (jabber-image-fetch
-                       url nil
-                       #'jabber-chat--replace-url-with-image
-                       url beg end buf))))))))))))
+          (let* ((url (match-string-no-properties 0))
+                 (bounds (jabber-chat--isolate-image-url
+                          (match-beginning 0) (match-end 0))))
+            (jabber-chat--scan-image-url
+             url (car bounds) (cdr bounds) auto)))))))
 
 (defun jabber-chat-goto-address (_msg _who mode)
   "Call function `goto-address' on the newly written text (MODE = :insert)."
diff --git a/tests/jabber-test-chat.el b/tests/jabber-test-chat.el
index 78556f4d54..453627e6d6 100644
--- a/tests/jabber-test-chat.el
+++ b/tests/jabber-test-chat.el
@@ -953,6 +953,143 @@ JC is a fake connection from 
`jabber-test-chat--make-fake-jc'."
 (ert-deftest jabber-test-chat-aesgcm-image-nil-body-returns-nil ()
   (should-not (jabber-chat--aesgcm-image-from-body nil "key" "iv" nil)))
 
+;;; Group 14: image display policy
+
+(defun jabber-test-chat--make-jc-with-roster (&rest jids)
+  "Create a fake connection whose roster contains JIDS."
+  (let ((jc (gensym "jabber-test-chat-jc-")))
+    (put jc :state-data (list :roster (mapcar #'jabber-jid-symbol jids)))
+    jc))
+
+(defmacro jabber-test-chat--with-policy-buffer (peer &rest body)
+  "Run BODY in a temp buffer chatting with PEER (nil for a MUC).
+The fake connection has [email protected] on its roster."
+  (declare (indent 1))
+  `(with-temp-buffer
+     (setq-local jabber-buffer-connection
+                 (jabber-test-chat--make-jc-with-roster "[email protected]"))
+     (let ((peer ,peer))
+       (when peer
+         (setq-local jabber-chatting-with peer)))
+     ,@body))
+
+(ert-deftest jabber-test-chat-auto-display-t-always ()
+  (jabber-test-chat--with-policy-buffer nil
+    (let ((jabber-chat-display-images t))
+      (should (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-legacy-non-nil-value ()
+  "Any non-nil value other than `roster' behaves like t."
+  (jabber-test-chat--with-policy-buffer nil
+    (let ((jabber-chat-display-images 'always))
+      (should (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-nil-never ()
+  (jabber-test-chat--with-policy-buffer "[email protected]"
+    (let ((jabber-chat-display-images nil))
+      (should-not (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-roster-contact ()
+  (jabber-test-chat--with-policy-buffer "[email protected]"
+    (let ((jabber-chat-display-images 'roster))
+      (should (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-roster-full-jid ()
+  (jabber-test-chat--with-policy-buffer "[email protected]/laptop"
+    (let ((jabber-chat-display-images 'roster))
+      (should (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-roster-stranger ()
+  (jabber-test-chat--with-policy-buffer "[email protected]"
+    (let ((jabber-chat-display-images 'roster))
+      (should-not (jabber-chat--auto-display-images-p)))))
+
+(ert-deftest jabber-test-chat-auto-display-roster-muc ()
+  "MUC buffers have no `jabber-chatting-with' and never auto-display."
+  (jabber-test-chat--with-policy-buffer nil
+    (let ((jabber-chat-display-images 'roster))
+      (setq-local jabber-group "[email protected]")
+      (should-not (jabber-chat--auto-display-images-p)))))
+
+;;; Group 15: image URL scan behavior
+
+(defconst jabber-test-chat--scan-url "https://example.com/pic.png";)
+
+(defmacro jabber-test-chat--with-scan-buffer (&rest body)
+  "Run BODY in a temp buffer containing one image URL.
+Bind `fetches' to the recorded `jabber-chat--start-image-fetch'
+calls and `url', `beg' and `end' to the URL and its bounds."
+  `(with-temp-buffer
+     (let ((fetches nil)
+           (url jabber-test-chat--scan-url))
+       (insert url)
+       (let ((beg (point-min))
+             (end (point-max)))
+         (cl-letf (((symbol-function 'jabber-chat--start-image-fetch)
+                    (lambda (&rest args) (push args fetches))))
+           ,@body)))))
+
+(ert-deftest jabber-test-chat-scan-auto-fetches-with-allowlist ()
+  (jabber-test-chat--with-scan-buffer
+   (jabber-chat--scan-image-url url beg end t)
+   (should (equal fetches
+                  (list (list url beg end jabber-chat-image-auto-types))))))
+
+(ert-deftest jabber-test-chat-scan-no-auto-still-clickable ()
+  "Without auto-display the URL is not fetched but stays actionable."
+  (jabber-test-chat--with-scan-buffer
+   (jabber-chat--scan-image-url url beg end nil)
+   (should (null fetches))
+   (should (equal (get-text-property beg 'jabber-chat-image-url) url))
+   (should (eq (get-text-property beg 'keymap) jabber-chat-url-keymap))))
+
+(ert-deftest jabber-test-chat-scan-skips-failed-fetch ()
+  (jabber-test-chat--with-scan-buffer
+   (put-text-property beg end 'jabber-chat-image-fetching 'failed)
+   (jabber-chat--scan-image-url url beg end t)
+   (should (null fetches))))
+
+(ert-deftest jabber-test-chat-scan-skips-in-flight-fetch ()
+  (jabber-test-chat--with-scan-buffer
+   (put-text-property beg end 'jabber-chat-image-fetching url)
+   (jabber-chat--scan-image-url url beg end t)
+   (should (null fetches))))
+
+(ert-deftest jabber-test-chat-scan-restores-cached-despite-policy ()
+  "A cached image is displayed even when auto-display is off."
+  (jabber-test-chat--with-scan-buffer
+   (unwind-protect
+       (progn
+         (jabber-chat--cache-image url '(image :type png))
+         (jabber-chat--scan-image-url url beg end nil)
+         (should (null fetches))
+         (should (get-text-property beg 'display)))
+     (remhash url jabber-chat--image-cache))))
+
+(ert-deftest jabber-test-chat-failed-fetch-marks-url ()
+  "A nil image from the fetcher marks the URL range as failed."
+  (with-temp-buffer
+    (insert jabber-test-chat--scan-url)
+    (let ((beg (copy-marker (point-min)))
+          (end (copy-marker (point-max))))
+      (jabber-chat--replace-url-with-image
+       nil jabber-test-chat--scan-url beg end (current-buffer))
+      (should (eq (jabber-chat--image-fetch-state (point-min)) 'failed)))))
+
+(ert-deftest jabber-test-chat-isolate-image-url-inserts-newline ()
+  (with-temp-buffer
+    (insert "text https://example.com/pic.png";)
+    (let* ((end (point-max))
+           (bounds (jabber-chat--isolate-image-url 6 end)))
+      (should (equal (cons 7 (1+ end)) bounds))
+      (should (eq (char-before (car bounds)) ?\n)))))
+
+(ert-deftest jabber-test-chat-isolate-image-url-already-alone ()
+  (with-temp-buffer
+    (insert "https://example.com/pic.png";)
+    (should (equal (cons 1 (point-max))
+                   (jabber-chat--isolate-image-url 1 (point-max))))))
+
 (provide 'jabber-test-chat)
 
 ;;; jabber-test-chat.el ends here

Reply via email to