This repository has been archived by the owner on Mar 14, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 20
/
Copy pathmatrix-client-room.el
1607 lines (1465 loc) · 80.2 KB
/
matrix-client-room.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
(require 'anaphora)
(require 'cl-lib)
(require 'dnd)
(require 'map)
(require 'pcomplete)
(require 'shr)
(require 'ox-html)
(require 'matrix-macros)
(require 'matrix-client-rainbow)
(require 'ordered-buffer)
(require 'dash-functional)
(require 'esxml-query)
;;;; Variables
(defvar matrix-client-insert-prefix-fn nil
"When set, `matrix-client-insert' will call this function before inserting.
Used to add a button for pending messages.")
(defvar matrix-client-mode-map
(let ((map (make-sparse-keymap))
(mappings `(
"C-c C-n" matrix-client-switch-to-notifications-buffer
"C-c C-r" matrix-client-room-list
"r" matrix-client-reply-or-insert
"R" (lambda () (interactive) (matrix-client-reply-or-insert t))
"RET" matrix-client-ret
"DEL "matrix-client-delete-backward-char
"M-v" matrix-client-scroll-down
"C-k" matrix-client-kill-line-or-unsent-message
"TAB" matrix-client-tab
"<backtab>" (lambda ()
(interactive)
(matrix-client-tab :backward t))
;; This seems to work properly, to get the binding from `org-mode-map'.
,(key-description (where-is-internal 'org-edit-special org-mode-map 'first-only)) matrix-client-room-outorg)))
(cl-loop for (key fn) on mappings by #'cddr
do (define-key map (cl-typecase key
(string (kbd key))
(otherwise key))
fn))
map)
"Keymap for `matrix-client-mode'.")
(defgroup matrix-client-room nil
"Room buffer settings."
:group 'matrix-client)
(defcustom matrix-client-room-member-event-modifies-buffer nil
"Mark room buffer modified when a member joins/leaves."
:type 'boolean)
(defcustom matrix-client-room-save-outgoing-messages t
"Save outgoing messages in kill ring before sending.
This way, in the event that a message gets lost in transit, the
user can recover it from the kill ring instead of retyping it."
:type 'boolean)
(defcustom matrix-client-room-send-as-org-by-default t
"Send messages as Org-formatted text by default.
When disabled, use the \"/org\" command to send Org-formatted
text."
:type 'boolean)
(defcustom matrix-client-room-timestamp-header-delta 300
"Number of seconds between messages after which a timestamp header is shown."
:type 'integer)
(defvar matrix-client-room-commands nil
"List of room commands, without leading slash.
Used for completion.")
(defvar matrix-client-room-shr-external-rendering-functions
(a-list 'mx-reply #'matrix-client--shr-mx-reply)
"Functions used to render HTML in Matrix messages. See `shr-external-rendering-functions'.")
;;;; Macros
(cl-defmacro matrix-client-defevent (type docstring &key object-slots event-keys content-keys let body)
"Define a method on `matrix-room' to handle Matrix events of TYPE.
TYPE should be a symbol representing the event type,
e.g. `m.room.message'.
DOCSTRING should be a docstring for the method.
OBJECT-SLOTS should be a list of lists, each in the form (OBJECT
SLOT ...), which will be turned into a `with-slots*' form
surrounding the following `pcase-let*' and BODY. (This form
seems more natural than the (SLOTS OBJECT) form used by
`with-slots'.)
The following are bound in order in `pcase-let*':
EVENT-KEYS should be a list of symbols in the EVENT alist which
are bound with `pcase-let*' around the body. These keys are
automatically bound: `content', `event_id', `sender',
`origin_server_ts', `type', and `unsigned'.
CONTENT-KEYS should be a list of symbols in the EVENTs `content'
key, which are bound in the `pcase-let*' around the body.
LET should be a varlist which is bound in the `pcase-let*' around
the body.
BODY will finally be evaluated in the context of these slots and
variables.
It is hoped that using this macro is easier than defining a large
method without it."
;; FIXME: It would probably be better to use the same form for OBJECT-SLOTS that is used by
;; `pcase-let*', because having two different ways is too confusing.
(declare (indent defun)
(debug (&define symbolp stringp
&rest [&or [":body" def-form] [keywordp listp]])))
(let ((method-name (intern (concat "matrix-client-event-" (symbol-name type))))
(slots (cl-loop for (object . slots) in object-slots
collect (list slots object))))
`(defun ,method-name (room event)
,docstring
(declare (indent defun))
(with-slots* ,slots
(pcase-let* (((map content event_id sender origin_server_ts type unsigned ,@event-keys) event)
((map ,@content-keys) content)
,@let)
,body)))))
;;;; Commands
(defun matrix-client-mouse-browse-link (event)
"Call `browse-url' for link at mouse click EVENT.
URL taken from `help-echo' text property."
(interactive)
(mouse-set-point event)
(when-let ((url (get-text-property (point) 'help-echo)))
(browse-url url)))
(defun matrix-client-scroll-down ()
"Call `scroll-down-command'. If point is at the top of the buffer, load history."
(interactive)
(if (= (line-number-at-pos (point)) 1)
(matrix-client-fetch-history matrix-client-room)
(let ((scroll-error-top-bottom t))
(scroll-down-command))))
(defun matrix-client-kill-line-or-unsent-message (&optional message)
"Kill current line; with prefix, kill everything after prompt."
(interactive "P")
(if message
(progn
(goto-char (matrix-client--prompt-position))
(kill-region (point) (point-max)))
(call-interactively #'kill-visual-line)))
(cl-defun matrix-client-tab (&key backward)
"If point is before prompt, move point to next event; otherwise complete room member names/IDs."
(interactive)
(if-let* ((pos (matrix-client--next-event-pos :backward backward)))
(goto-char pos)
(pcomplete)))
(defun matrix-client-ret ()
"If point is before prompt, move point to prompt; otherwise call `matrix-client-send-active-line'."
(interactive)
(let ((prompt (matrix-client--prompt-position)))
(if (< (point) prompt)
(goto-char prompt)
(call-interactively #'matrix-client-send-input))
(matrix-client-update-last-seen matrix-client-room)))
(defun matrix-client-reply-or-insert (&optional quote-p)
"If point is on a previous message, begin a reply addressed to its sender. Otherwise, self-insert.
With prefix, quote message or selected region of message."
(interactive "P")
(if-let* ((sender (get-text-property (point) 'sender)))
;; Start reply.
;; Getting room from text property is for using from notifications buffer.
(let* ((room (or (get-text-property (point) 'room)
matrix-client-room))
(string (if quote-p
(concat (matrix-client-quote-event-at-point :org matrix-client-room-send-as-org-by-default) "\n\n")
(propertize (concat (matrix-user-displayname room sender) ": ")
'room room
'sender sender
'quoted-body (matrix-client--this-message)
'reply-p t
;; local-label means to remove this part before sending a message.
;; FIXME: This is all very ugly. Needs better names too.
'local-label t
'rear-nonsticky t
'event_id (get-text-property (point) 'event_id)))))
(goto-char (matrix-client--prompt-position))
(insert string))
;; Do self-insert
(call-interactively 'self-insert-command)))
(cl-defun matrix-client-quote-event-at-point (&key org)
"Return event at point quoted for replying.
If ORG is non-nil, use Org syntax."
(let* ((display-name (get-text-property (point) 'displayname))
(sender (get-text-property (point) 'sender))
(event-id (get-text-property (point) 'event_id))
(quote (--> (if (use-region-p)
(buffer-substring (region-beginning) (region-end))
(matrix-client--this-message))
(s-trim it)
(prog1 it
(remove-text-properties 0 (length it) '(read-only t) it))))
(string (if org
(concat "#+BEGIN_QUOTE\n" quote "\n#+END_QUOTE")
quote)))
;; The `quoted-body' property is used to help display quotes sent by our own client properly.
;; NOTE: There appears to be no Emacs function to get all text properties in a string, and doing
;; so might be inefficient and not even make sense, because a single property can have multiple
;; values in a string. So it's important to put all the properties we want other functions to
;; have access to at the beginning of the string.
(propertize (concat display-name ": \n\n" string)
'event_id event-id
'sender sender
'quoted-body quote
'rear-nonsticky '(quoted-body))))
(defun matrix-client-delete-backward-char (n &optional kill-flag)
"Delete backward unless the point is at the prompt or other read-only text."
(interactive "p\nP")
(unless (get-text-property (- (point) 2) 'read-only)
(call-interactively #'delete-backward-char n kill-flag)))
(cl-defun matrix-client--room-input (&key delete)
"Return room input without read-only text properties.
If DELETE is non-nil, also delete it from the input line."
(let* ((fn (if delete
#'delete-and-extract-region
#'buffer-substring))
(text (funcall fn (matrix-client--prompt-position) (point-max))))
(remove-text-properties 0 (length text) '(read-only t) text)
text))
(defun matrix-client--room-command (name)
"If NAME is a room command, return its function, otherwise nil."
(let* ((fn-name (format "matrix-client-room-command-%s" name))
(fn (intern-soft fn-name)))
(when (functionp fn)
fn)))
(cl-defun matrix-client-send-input (&key html input)
"Send current input to current room.
If HTML is non-nil, treat input as HTML."
;; FIXME: Do we still need the html arg?
(interactive)
;; This is surprisingly tedious to get correct. It has to handle these cases:
;;
;; + Empty input
;; + Invalid command without args
;; + Invalid command with args
;; + Valid command without args
;; + Valid command with args
;; + Message without command
;;
;; This is something like version 3.5 of this function. Seems to work properly now.
(-let* ((input (or input
(matrix-client--room-input :delete t)))
((command args) (progn
;; This should always match, even an empty string, so no errors.
(string-match (rx bos
(seq (optional "/" (group (1+ (not blank))))
(optional (1+ blank))
(optional (group (1+ anything)))))
input)
(list (match-string 1 input)
(match-string 2 input))))
(command-fn (if command
(matrix-client--room-command command)
(when (and matrix-client-room-send-as-org-by-default args)
;; Only call /org when input is non-empty.
(matrix-client--room-command "org")))))
(when (and command (not command-fn))
;; Invalid command
(goto-char (matrix-client--prompt-position))
(insert input)
(user-error "Invalid room command: /%s (to send messages starting with \"/\", insert a space first)" command))
(when (or command-fn (not (s-blank-str? args)))
;; Valid command or normal message
(when matrix-client-room-save-outgoing-messages
(push input kill-ring))
(if command-fn
(funcall command-fn matrix-client-room args)
(matrix-client-send-input-1 :input args :html html)))))
(cl-defun matrix-client-send-input-1 (&key input html)
"Send input to current room as a message.
If HTML is non-nil, treat INPUT as HTML."
(pcase-let* (;; (matrix-client-insert-prefix-fn
;; ;; FIXME: I don't think this works anymore.
;; (lambda ()
;; (insert-button "[pending] "
;; 'face 'matrix-client-pending-messages
;; 'action (lambda (&rest ignore)
;; (when (yes-or-no-p "Resend message?")
;;
;; (matrix-send-message room string
;; :override-txn-id (1+ txn-id))))
;; 'help-echo "Resend message"
;; 'transaction_id (1+ txn-id))))
(room matrix-client-room)
((eieio session (id room-id)) room)
((eieio user txn-id) session)
(plain-text-body) (format) (formatted-body) (extra-content))
(when (or (get-text-property 0 'event_id input)
(text-property-not-all 0 (length input) 'quoted-body nil input))
;; Replying or quoting.
;; FIXME: This is better than the old code, but still feels messy. The
;; quoting code should probably be moved to another function.
(let* ((reply-p (get-text-property 0 'reply-p input))
;; In case a user copies and pastes a message from one room buffer into another, it
;; will appear as a reply, but we won't have a quoted-body, so use an empty string to
;; prevent "nil".
(quotation (or (get-text-property 0 'quoted-body input) ""))
(event-id (or (get-text-property 0 'event_id input)
(display-warning 'matrix-client-send-input-1 "Event ID not found in quotation: %s" quotation)))
(sender (get-text-property 0 'sender input))
(sender-displayname (matrix-user-displayname room sender))
(byline (format$ "<a href=\"https://matrix.to/#/$room-id/$event-id\">In reply to</a> <a href=\"https://matrix.to/#/$sender\">$sender-displayname</a><br>"))
;; Remove quoted body from input to avoid double-quoting.
(input (or (get-text-property 0 'non-quoted-part input)
(when (get-text-property 0 'local-label input)
(substring input (next-single-property-change 0 'local-label input)))
input)))
;; BUG: If replying to a message, the propertized quotation
;; part with event ID prevents the rest of the reply from
;; being handled as Org syntax.
(setq html t
;; NOTE: Trying to imitate Riot here, except that we send "m.relates_to/m.in_reply_to"
;; even when "quoting", because it seems to make sense to do so. Riot doesn't do it,
;; probably because it would have to adjust the UI code to not print the entire
;; message being replied to. But without sending the event ID it relates to, there's
;; no definitive link to the message being replied to, so I think we should send it
;; anyway. This means that when quoting part of a message, Riot will also display the
;; entire message body above the quoted portion...but so what.
formatted-body (if reply-p
;; A "reply"
(concat "<mx-reply><blockquote>" byline quotation "</blockquote></mx-reply>" input)
;; Not a reply but a "quote"
(format$ "<blockquote>$quotation</blockquote>$input"))
plain-text-body (let ((quotation (matrix-client--html-to-plain quotation))
(input (matrix-client--html-to-plain input)))
(if reply-p
(format$ "> <$sender> $quotation\n$input")
;; NOTE: Riot omits the leading ">" in this case, but that seems like a bug, so we'll include it.
(format$ "> $quotation $input")))
extra-content (a-list 'm.relates_to (a-list 'm.in_reply_to (a-list 'event_id event-id))))))
(if html
(setq format "org.matrix.custom.html"
formatted-body (or formatted-body input)
plain-text-body (or plain-text-body
(matrix-client--html-to-plain input))
extra-content (append extra-content
(a-list 'format format
'formatted_body formatted-body)))
(setq plain-text-body input))
(if (matrix-send-message room plain-text-body
:extra-content extra-content
:success (apply-partially #'matrix-client-send-message-callback room
;; HACK: We have to get the txn-id
;; ourselves here so we can apply it to the
;; callback, before send-message returns
;; the txn-id.
(1+ txn-id))
:error (apply-partially #'matrix-client-send-message-error-callback room
(1+ txn-id)))
(progn
;; Message sent: insert fake message while waiting for server response.
(matrix-client-event-m.room.message
room (a-list 'origin_server_ts (* 1000 (string-to-number (format-time-string "%s")))
'sender user
'unsigned (a-list 'transaction_id (1+ txn-id))
'content (a-list 'body plain-text-body
'msgtype "m.text"
'format format
'formatted_body formatted-body)
'type "m.room.message"))
(matrix-client-update-last-seen room))
(display-warning 'matrix-client-send-input-1 "`matrix-send-message' failed."))
(matrix-client-room-typing room nil)))
(defun matrix-client--event-body (id)
"Return event message body for ID."
;; NOTE: Currently unused, but leaving in because it may be useful.
(save-excursion
;; NOTE: `matrix--prev-property-change' is actually returning the point at which the property
;; CEASES to have the value, rather than where the value begins. I don't like that, but
;; changing that function would break a lot of other things, so I'm not going to do that now.
(when-let* ((metadata-start (matrix--prev-property-change (point-max) 'event_id id))
(message-start (next-single-property-change metadata-start 'face))
(message-end (next-single-property-change metadata-start 'event_id)))
(s-trim (buffer-substring message-start message-end)))))
(defun matrix-client--html-to-plain (html)
"Return plain-text rendering of HTML."
;; `shr-insert-document' insists on wrapping lines, so we disable the function it uses.
(cl-letf (((symbol-function 'shr-fill-line) (lambda (&rest ignore) nil)))
(let* ((tree (with-temp-buffer
(insert html)
(libxml-parse-html-region (point-min) (point-max))))
(plain-text (with-temp-buffer
(shr-insert-document tree)
(buffer-substring-no-properties (point-min) (point-max)))))
(s-trim plain-text))))
(defun matrix-client-upload (room path)
"Upload file at PATH to ROOM.
PATH may be a local path, optionally prefixed with \"file://\",
or a remote HTTP(S) path, in which case the file will be
downloaded and then uploaded. Prompts for confirmation before
uploading.
Interactively, uploads to current buffer's room, and completes
local file path; with prefix, reads path/URL without completion."
(interactive (list matrix-client-room
(if current-prefix-arg
(read-string "Path/URL: ")
(read-file-name "Upload file: " nil nil 'confirm))))
(when (yes-or-no-p (format "Really upload %s? " path))
(message "Uploading %s..." path)
(matrix-upload room (pcase path
;; NOTE: `url-file-local-copy' is synchronous; might be nice to do this
;; with a callback.
((rx bos "http" (optional "s") "://")
(or (url-file-local-copy path)
(error "Download failed (%s)" path)))
((rx bos "file://" (let local-path (1+ anything)))
local-path)
(_ path)))))
;;;; Methods
(defun matrix-client-fetch-history (room)
"Load earlier messages for ROOM."
(matrix-client-room-banner room "Loading history...")
(matrix-messages room ))
(cl-defun matrix-client-fetch-history-callback (room &key data &allow-other-keys)
(pcase-let* (((map start end chunk) data)
(matrix-client-enable-notifications nil)) ; Silence notifications for old messages
;; NOTE: We don't add the events to the timeline of the room object.
(seq-doseq (event chunk)
(matrix-event room event))
;; NOTE: When direction is "b", as it is when fetching earlier messages, the "end" token is the
;; earliest chronologically, so it becomes the room's new "start" token. Not confusing at
;; all... (maybe API 0.3.0 is better)
(matrix-client-room-banner room nil)))
(defun matrix-client-room-banner (room message)
"Display MESSAGE in a banner overlay at top of ROOM's buffer.
If MESSAGE is nil, clear existing message."
(with-room-buffer room
(let ((ov (or (car (ov-in 'matrix-client-banner))
(ov (point-min) (point-min)
'matrix-client-banner t)))
(message (when message
(propertize message
'face 'font-lock-comment-face))))
(ov-set ov 'before-string message))))
(defun matrix-client--delete-event (room plist)
"Delete event with text properties in PLIST from ROOM's buffer."
;; MAYBE: Run the m.room.message handler in `with-current-buffer' so we don't have to do it here.
;; Could reduce the number of times `with-current-buffer' is used, which may be slow.
(with-room-buffer room
(-when-let* (((beg end) (matrix-client--find-propertized-string plist))
(inhibit-read-only t))
(delete-region beg end))))
(cl-defun matrix-client-send-message-callback (room txn-id &key data &allow-other-keys)
"Client callback for send-message.
Replacing pending button with normal message event."
;; NOTE: ewoc.el might make this easier...
(matrix-log (a-list :event 'matrix-client-send-message-callback
:txn-id txn-id
:data data))
(pcase-let* (((eieio session) room)
((eieio user) session)
((map event_id) data)
(inhibit-read-only t))
(with-room-buffer room
(-when-let* (((beg end) (matrix-client--find-propertized-string (list 'transaction_id txn-id))))
(add-text-properties beg end (list 'event_id event_id))
;; Remove "pending" overlay
(--when-let (car (ov-in 'transaction_id txn-id))
(delete-region (ov-beg it) (ov-end it))
(delete-overlay it))))))
(cl-defun matrix-client-send-message-error-callback (room txn-id &key data &allow-other-keys)
"Client error callback for send-message.
Update [pending] overlay."
;; NOTE: ewoc.el might make this easier...
(matrix-log (a-list :event 'matrix-client-send-message-error-callback
:txn-id txn-id))
(pcase-let* (((eieio session) room)
((eieio user) session))
(with-room-buffer room
;; MAYBE: Should probably make a little library to insert and replace things in the buffer...
(if-let* ((inhibit-read-only t)
;; MAYBE: Ensure that only one overlay is found.
(ov (car (ov-in 'transaction_id txn-id)))
(beg (ov-beg ov))
(end (ov-end ov)))
(progn ;; Found message
(delete-region beg end)
(goto-char beg)
;; This should insert into the overlay
(insert (propertize "[FAILED] "
'face 'matrix-client-failed-messages)))
;; Message not found
(matrix-error (a-list 'event 'matrix-client-send-message-error-callback
'error "Can't find transaction"
:txn-id txn-id))))))
(defvar matrix-client-ordered-buffer-point-fn
(lambda (timestamp)
(funcall #'ordered-buffer-point-fn
:backward-from #'matrix-client--prompt-position
:property 'timestamp
:value timestamp
:comparator #'<=
:final-fn (lambda ()
(unless (matrix--next-property-change (point) 'timestamp)
;; `timestamp' property doesn't change after this point, so
;; we're at the bottom of the buffer, so if the last seen
;; line is past this position, move point it.
(let* ((last-seen-ov (car (ov-in 'matrix-client-last-seen)))
(ov-pos (ov-beg last-seen-ov)))
(when (> ov-pos (point))
(goto-char (ov-end (car (ov-in 'matrix-client-last-seen))))))))))
"Used to override point function when fetching old messages.")
(cl-defun matrix-client-insert (room string &key update timestamp-prefix)
"Insert STRING into ROOM's buffer.
STRING should have a `timestamp' text-property, or the current
timestamp will be added.
UPDATE may be a plist, in which case the buffer will be searched
for an existing item having text properties matching the keys and
values in UPDATE; if found, it will be replaced with STRING,
otherwise a new item will be inserted.
If TIMESTAMP-PREFIX is non-nil, STRING will be prefixed with the
formatted timestamp.
If `matrix-client-insert-prefix-fn' is non-nil, call that function with
point positioned before the inserted message."
;; TODO: Convert more callers to use `timestamp-prefix'.
(unless (get-text-property 0 'timestamp string)
(put-text-property 0 (length string) 'timestamp (string-to-number (format-time-string "%s")) string))
(with-room-buffer room
(save-excursion
(let* ((inhibit-read-only t) ; MAYBE: use buffer-read-only mode instead
(timestamp (get-text-property 0 'timestamp string))
(event-id (get-text-property 0 'event_id string))
(non-face-properties (cl-loop for (key val) on (text-properties-at 0 string) by #'cddr
unless (member key '(face display))
append (list key val)))
(timestamp-prefix (when timestamp-prefix
;; Apply face property from beginning of `string'.
(propertize (concat (format-time-string "[%T]" timestamp) " ")
'face (or (get-text-property 0 'face string)
'matrix-client-metadata))))
(string (apply #'propertize (concat timestamp-prefix string "\n") 'read-only t non-face-properties)))
(unless (and update
;; Inserting our own message, received back in /sync
(matrix-client--replace-string update string))
;; Inserting someone else's message, or our own from earlier sessions
(let ((ordered-buffer-prefix-fn (apply-partially #'matrix-client--ordered-buffer-prefix-fn timestamp))
(ordered-buffer-point-fn (apply-partially matrix-client-ordered-buffer-point-fn timestamp)))
;; MAYBE: Ensure event before point doesn't have the same ID. Removed this check when
;; switched to ordered-buffer, not sure if necessary.
(ordered-buffer-insert string 'timestamp timestamp)))
;; Update tracking
(unless (matrix-client-buffer-visible-p)
(set-buffer-modified-p t)
(when matrix-client-use-tracking
;; TODO handle faces when receving highlights
(tracking-add-buffer (current-buffer))))))))
(defun matrix-client--ordered-buffer-prefix-fn (timestamp)
"Insert headers at point if necessary, depending on TIMESTAMP."
;; FIXME: When inserting from point-min, this should look at the next event, not the previous one.
;; May want to use a defvar, maybe something like `ordered-buffer-insertion-direction'.
(let* ((ordered-buffer-header-face 'matrix-client-date-header)
(previous-timestamp (unless (bobp)
(save-excursion
(--when-let (cl-loop for ov in (overlays-at (point))
when (ov-val ov 'matrix-client-last-seen)
return ov)
;; Point is after the last-seen line: look behind it for the timestamp.
(goto-char (ov-beg it)))
(get-text-property (1- (point)) 'timestamp))))
(day-number (time-to-days timestamp))
(previous-day-number (when previous-timestamp
(time-to-days previous-timestamp))))
(when (or (not previous-day-number)
(not (= previous-day-number day-number)))
(let ((ordered-buffer-header-face '(:inherit matrix-client-date-header :height 1.5))
(ordered-buffer-header-suffix nil))
(ordered-buffer-insert-header (matrix-client--human-format-date timestamp)
'timestamp (->> timestamp
(format-time-string "%Y-%m-%d 00:00:00")
date-to-time
time-to-seconds)
'matrix-client-day-header t)))
(when (or (not previous-timestamp)
(>= (abs (- timestamp previous-timestamp)) matrix-client-room-timestamp-header-delta))
;; NOTE: When retrieving earlier messages, this inserts a new hour:minute header before every
;; batch of messages. That's not consistent with `matrix-client-room-timestamp-header-delta',
;; but it does visually distinguish each batch of old messages, which is helpful, so I'm going
;; to leave this behavior for now. If we decide it's not what we want, we could do something
;; like check the next timestamp rather than the previous one, when inserting newer messages.
(ordered-buffer-insert-header (format-time-string "%H:%M" timestamp)
'timestamp (->> timestamp
(format-time-string "%Y-%m-%d %H:%M:00")
date-to-time
time-to-seconds)))))
(defun matrix-client-update-last-seen (room &rest _)
"Move the last-seen overlay to after the last message in ROOM."
(with-room-buffer room
;; FIXME: Does this need to be when-let? Shouldn't these always be found?
(when-let ((seen-ov (car (ov-in 'matrix-client-last-seen)))
(target-pos (1- (matrix-client--prompt-position))))
(ov-move seen-ov target-pos target-pos))))
(defun matrix-client-replay (room)
"Erase and replay events into ROOM's buffer."
;; FIXME: Probably use `with-silent-modifications' here.
(with-room-buffer room
(let ((inhibit-read-only t)
(matrix-client-notifications nil)
(buffer-was-modified-p (buffer-modified-p (current-buffer))))
(ov-clear)
(erase-buffer)
(matrix-client-insert-prompt)
(matrix-client-insert-last-seen)
(cl-loop for event in (reverse (oref room timeline))
do (matrix-client-timeline room event))
(set-buffer-modified-p buffer-was-modified-p))))
;;;;; Room metadata
(defun matrix-client-rename-room-buffers (session)
"Rename all room buffers in SESSION.
Should be called after initial sync."
;; After initial sync timelines are processed, we run the room metadata hook to set the
;; room buffer names (which we do not do during processing of timelines during initial
;; sync, because doing so for every user "join" event is very slow.
(dolist (room (oref session rooms))
(matrix-client-rename-buffer room)
(with-room-buffer room
;; HACK: Set buffer to not modified. I don't feel like making another hook function now.
;; TODO: If we ever do caching, this should set the modification flag based on whether it has unseen messages.
(set-buffer-modified-p nil))))
(add-hook 'matrix-after-initial-sync-hook #'matrix-client-rename-room-buffers)
(defun matrix-client-rename-buffer (room)
"Rename ROOM's buffer."
(with-room-buffer room
(rename-buffer (or (oref room display-name)
;; HACK: The room name ought to have been set already, but if e.g. the
;; hook functions were run in the wrong order, it might not be.
(oset room display-name (matrix--room-display-name room)))
'unique)))
(defun matrix-client-update-header (room)
"Update the header line of the current buffer for ROOM.
Also update prompt with typers."
(unless (and (boundp 'tabbar-mode) tabbar-mode)
;; Disable when tabbar mode is on. MAYBE: Remove this.
(with-room-buffer room
(pcase-let* (((eieio avatar typers name topic session) room)
((eieio user) session)
(name (when name
(propertize name 'face 'font-lock-keyword-face)))
(ov (car (ov-in 'matrix-client-prompt)))
(typers-string (s-join ", " (cl-loop for typer across typers
unless (string= user typer)
collect (matrix-user-displayname room typer))))
(prompt (if (s-present? typers-string)
(concat (propertize (concat "Typing: " typers-string)
'face 'font-lock-comment-face)
"\n" matrix-client-input-prompt)
matrix-client-input-prompt)))
(ov-set ov 'before-string prompt)
(setq header-line-format (concat avatar
;; NOTE: Not sure if using `format' with an image-containing string works.
(format$ " $name: $topic")))))))
(add-hook 'matrix-room-metadata-hook #'matrix-client-update-header)
;;;;; Room buffer setup
(defvar matrix-client-setup-room-buffer-hook nil
"Hook run after a room buffer is set up.
Called from inside the room's buffer.")
(defun matrix-client-setup-room-buffer (room)
"Prepare and switch to buffer for ROOM-ID, and return room object."
(with-room-buffer room
(matrix-client-mode)
(visual-line-mode 1)
(setq buffer-undo-list t)
;; Unset buffer's modified status when it's selected
(when matrix-client-mark-modified-rooms
(add-hook 'buffer-list-update-hook #'matrix-client-buffer-list-update-hook 'append 'local))
(erase-buffer)
;; FIXME: Remove these or update them.
;; (set (make-local-variable 'matrix-client-room-connection) con)
(setq-local matrix-client-room room)
;; Load notification settings for room
(with-slots (id client-data) room
(when-let* ((rules (a-get matrix-client-room-notification-rules id)))
(oset client-data notification-rules rules)))
;; Drag-and-drop support
(setq-local x-dnd-test-function #'matrix-client--x-dnd-test-function)
(setq-local dnd-protocol-alist
;; Support dropping URLs, e.g. from Dolphin or Firefox. Copied from `dnd-protocol-alist'.
;; NOTE: Chrome/Chromium does not properly handle the XDndActionPrivate action, so
;; drag-and-drops from Chrome/Chromium fail. Google has failed to fix this bug in
;; nearly 4 years since it was reported, and automatically closed the bug report a
;; year ago because it was "old, likely obsolete." See
;; <https://bugs.chromium.org/p/chromium/issues/detail?id=461390>,
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=19885>.
'(("^file:///" . matrix-client--dnd-open-local-file)
("^file://" . matrix-client--dnd-open-file)
("^file:" . matrix-client--dnd-open-local-file)
("^\\(https?\\|ftp\\|file\\|nfs\\)://" . matrix-client--dnd-open-file)))
;; Typing notifications
(add-hook 'post-self-insert-hook (lambda (&rest _args)
(matrix-client-room-typing matrix-client-room t))
nil t)
(when matrix-client-use-tracking
(tracking-mode 1))
(matrix-client-insert-prompt)
(matrix-client-insert-last-seen)
(run-hooks 'matrix-client-setup-room-buffer-hook)))
(defun matrix-client-insert-last-seen ()
"Insert last-seen overlay into current buffer."
(when-let ((prompt-ov (car (ov-in 'matrix-client-prompt)))
(target-pos (1- (ov-beg prompt-ov))))
(ov target-pos target-pos
'before-string (concat "\n" (propertize "\n\n" 'face 'matrix-client-last-seen))
'matrix-client-last-seen t)))
(defun matrix-client-insert-prompt ()
"Insert prompt into ROOM's buffer."
(let ((inhibit-read-only t)
(ov-sticky-front t))
(goto-char (point-max))
(insert (propertize "\n" 'read-only t)
"\n")
(ov (point) (point)
'before-string (concat (propertize "\n"
'face '(:height 0.1))
matrix-client-input-prompt)
'matrix-client-prompt t)))
;;;;;; Pcomplete
(defun matrix-client-room-pcomplete-setup ()
"Set buffer-local variables for `pcomplete'.
To be called in `matrix-client-setup-room-buffer-hook'."
(setq-local pcomplete-parse-arguments-function #'matrix-client-pcomplete-parse-arguments)
(setq-local pcomplete-default-completion-function #'matrix-client-pcomplete-room-members)
(setq-local pcomplete-use-paring nil)
(setq-local pcomplete-termination-string ": ")
(setq-local pcomplete-ignore-case t))
(add-hook 'matrix-client-setup-room-buffer-hook #'matrix-client-room-pcomplete-setup)
(defun matrix-client-pcomplete-room-members ()
"Throw `pcomplete-completions' with a list of room members.
List contains displaynames where available and MXIDs where not."
(throw 'pcomplete-completions
(-flatten
(--map (list (alist-get 'displayname it)
(car it))
(oref matrix-client-room members)))))
(defun matrix-client-pcomplete-parse-arguments ()
"Parse current input line and return argument list suitable for `pcomplete'."
;; This is all very confusing. Only with the help of
;; <https://www.emacswiki.org/emacs/PcompleteExamples> was I able to
;; figure it out. Thanks to whoever put together the simple example there.
(save-excursion
(list (list "dummy"
(s-trim (buffer-substring (point) (save-excursion
(backward-to-indentation)
(point)))))
(line-beginning-position) (point))))
;;;;; Room commands
(cl-defmacro matrix-client-def-room-command (name &key docstring message (msgtype "m.text") insert)
"Define a room command that sends the return value of FN as a message.
In all expressions evaluated, the variable `room' is bound to the
room object, and `input' is bound to the command's
argument (i.e. everything after \"/command\").
MESSAGE may be a lisp expression, the value of which is sent to
the room as a message.
MSGTYPE may be, e.g. \"m.text\" (the default), \"m.emote\",
etc (see API docs).
INSERT may be a lisp expression which evaluates to a string,
which is inserted in the room buffer. This happens after MESSAGE
is sent, if any."
(declare (indent defun))
(let* ((command (symbol-name name))
(method-name (intern (concat "matrix-client-room-command-" command))))
`(progn
(defun ,method-name (room input)
,docstring
(--when-let ,message
(matrix-send-message room it :msgtype ,msgtype))
(--when-let ,insert
(let ((matrix-client-insert-prefix-fn nil))
(matrix-client-insert room (matrix-client--notice-string it)
:timestamp-prefix t)))
(matrix-client-update-last-seen room))
(add-to-list 'matrix-client-room-commands ,command))))
(matrix-client-def-room-command help
:docstring "Display list of room commands."
:insert (concat "Room commands: "
(s-join ", " (--map (concat "/" it)
(-sort #'string< matrix-client-room-commands)))))
;; HACK: This /rainbow command cannot be defined in matrix-client-rainbow.el because the
;; byte-compiler fails for some weird reason.
(matrix-client-def-room-command rainbow
:docstring "Toggle `matrix-client-rainbow-mode' in current room."
:insert (progn
(message (if (call-interactively #'matrix-client-rainbow-mode)
"Look at all the colors!!"
"Boring mode engaged."))
nil))
(matrix-client-def-room-command priority
:docstring "Set room priority."
:insert (pcase input
((or "high" "favorite" "favourite")
(progn
(matrix-room-tags room '(m.favourite))
(matrix-room-tags room '(m.lowpriority) :action 'delete)
"Room set as favourite."))
((or "normal" "none")
(progn
(matrix-room-tags room '(m.favourite m.lowpriority) :action 'delete)
"Room set to normal priority."))
("low"
(progn
(matrix-room-tags room '(m.lowpriority))
(matrix-room-tags room '(m.favourite) :action 'delete)
"Room set to low priority."))
(_ "Priority may be: (high|favorite|favourite), (normal|none), or low.")))
(matrix-client-def-room-command raw
:docstring "Send message without formatting.
When `matrix-client-room-send-as-org-by-default' is non-nil, this
cancels Org formatting."
:message input)
(matrix-client-def-room-command org
:docstring "Send Org-formatted messages!"
;; There are probably other org-export settings that will be needed.
:insert (let* ((org-export-with-toc nil)
(org-export-with-broken-links t)
(org-export-with-section-numbers nil)
(org-html-inline-images nil)
(text-properties (text-properties-at 0 input))
(quoted-body (get-text-property 0 'quoted-body input))
(non-quoted-part (when (and quoted-body
(text-property-not-all 0 (length input) 'quoted-body quoted-body input))
;; This is getting really messy, but the problem is that, after
;; running the text through org-export, we lose the text
;; properties.
(substring input (next-single-property-change 0 'quoted-body input))))
(exported (save-window-excursion
(with-temp-buffer
(insert input)
(cl-letf (((symbol-function 'org-html-src-block)
(symbol-function 'matrix-client--org-html-src-block)))
(org-html-export-as-html nil nil nil 'body-only))
(with-current-buffer "*Org HTML Export*"
(prog1 (s-trim (buffer-string))
(kill-buffer)))))))
(matrix-client-send-input-1 :html t
:input (apply #'propertize exported
'non-quoted-part non-quoted-part
text-properties))
;; Return nil so nothing actually gets inserted directly from this command.
nil))
(matrix-client-def-room-command me
:message input
:msgtype "m.emote"
:docstring "Send emote to room.")
(matrix-client-def-room-command who
:insert (with-slots (members) room
(concat "Room members: "
(->> members
(--map (or (a-get (cdr it) 'displayname)
(car it))) ; MXID
(--sort (string-collate-lessp it other nil 'ignore-case))
(s-join ", "))))
:docstring "Print list of room members.")
(matrix-client-def-room-command join
:insert (pcase-let* (((eieio session) room))
;; Only accept one room
(if (> (length (s-split (rx (1+ space)) input)) 1)
(user-error "Invalid /join command")
(matrix-join-room session input)
(concat "Joining room: " input)))
:docstring "Join room on session.
INPUT should be, e.g. \"#room:matrix.org\".")
(matrix-client-def-room-command leave
:insert (when (matrix-leave room)
"Leaving room...")
:docstring "Leave current room.")
(matrix-client-def-room-command tags
:docstring "List or set room user-tags."
:insert (with-slots (tags) room
(cond (input
;; Delete existing user tags
(matrix-room-tags room (-map #'make-symbol (matrix-client-room-user-tags room))
:action 'delete)
(let ((new-tags (split-string input)))
;; Set new tags
(matrix-room-tags room (--map (make-symbol (concat "u." it))
new-tags))
(concat "Set room user-tags to: "
(s-join ", " new-tags))))
(t (concat "Current room user-tags: "
(let ((tags (matrix-client-room-user-tags room)))
(if tags
(s-join ", " (--map (substring it 2)
tags))
"none")))))))
(matrix-client-def-room-command tag
:docstring "Add room user-tags."
:insert (with-slots (tags) room
(cond (input
(let ((new-tags (split-string input)))
;; Set new tags
(matrix-room-tags room (--map (make-symbol (concat "u." it))
new-tags))
(concat "Added room user-tags: "
(s-join ", " new-tags))))
(t "You may be in the Matrix, but it can't read your mind."))))
(matrix-client-def-room-command untag
:docstring "Delete room user-tags."
:insert (with-slots (tags) room
(cond (input
(let ((deleting-tags (split-string input)))
(matrix-room-tags room (--map (make-symbol (concat "u." it))
deleting-tags)
:action 'delete)
(concat "Deleted room user-tags: "
(s-join ", " deleting-tags))))
(t "You may be in the Matrix, but it can't read your mind."))))
(matrix-client-def-room-command addtags
:docstring "Obsolete. Use /tag command."
:insert "/addtags is obsolete. Please use the /tag command.")
(matrix-client-def-room-command deltags
:docstring "Obsolete. Use /untag command."
:insert "/deltags is obsolete. Please use the /untag command.")
(matrix-client-def-room-command topic
:docstring "Set room topic."
:insert (if (not (s-blank-str? input))
(when (matrix-set-topic room input)
(concat "Changing topic to: " input))
(concat "Topic: " (oref room topic))))
(matrix-client-def-room-command name