015304d2af6f97c8594583e98952d067a916b27f
[citadel.git] / webcit-ng / static / js / view_forum.js
1 // This module handles the view for "forum" (message board) rooms.
2 //
3 // Copyright (c) 2016-2023 by the citadel.org team
4 //
5 // This program is open source software.  Use, duplication, or
6 // disclosure are subject to the GNU General Public License v3.
7
8
9 // Forum view (flat)
10 function forum_readmessages(target_div_name, gt_msg, lt_msg) {
11         target_div = document.getElementById(target_div_name);
12         original_text = target_div.innerHTML;                                   // in case we need to replace it after an error
13         target_div.innerHTML = ""
14
15         if (lt_msg < 9999999999) {
16                 url = "/ctdl/r/" + escapeHTMLURI(current_room) + "/msgs.lt|" + lt_msg;
17         }
18         else {
19                 url = "/ctdl/r/" + escapeHTMLURI(current_room) + "/msgs.gt|" + gt_msg;
20         }
21
22         fetch_msg_list = async() => {
23                 response = await fetch(url);
24                 msgs = await(response.json());
25                 if (response.ok) {
26                         target_div.innerHTML = "" ;
27
28                         // If we were given an explicit starting point, by all means start there.
29                         // Note that we don't have to remove them from the array because we did a 'msgs gt|xxx' command to Citadel.
30                         if (gt_msg > 0) {
31                                 msgs = msgs.slice(0, messages_per_page);
32                         }
33
34                         // Otherwise, show us the last 20 messages
35                         else {
36                                 if (msgs.length > messages_per_page) {
37                                         msgs = msgs.slice(msgs.length - messages_per_page);
38                                 }
39                                 new_old_div_name = randomString();
40                                 if (msgs.length < 1) {
41                                         newlt = lt_msg;
42                                 }
43                                 else {
44                                         newlt = msgs[0];
45                                 }
46                                 target_div.innerHTML +=
47                                         "<div id=\"" + new_old_div_name + "\">" +
48                                         "<div class=\"ctdl-forum-nav\" " +
49                                         "onclick=\"javascript:forum_readmessages('" + new_old_div_name + "', 0, " + newlt + ");\">" +
50                                         "<i class=\"fa fa-arrow-circle-up\"></i>&nbsp;&nbsp;" +
51                                         _("Older posts") + "&nbsp;&nbsp;<i class=\"fa fa-arrow-circle-up\"></div></div></a></div></div>" ;
52                         }
53
54                         // The messages will go here.
55                         let msgs_div_name = randomString();
56                         target_div.innerHTML += "<div id=\"" + msgs_div_name + "\"> </div>" ;
57
58                         if (lt_msg == 9999999999) {
59                                 new_new_div_name = randomString();
60                                 if (msgs.length <= 0) {
61                                         newgt = gt_msg;
62                                 }
63                                 else {
64                                         newgt = msgs[msgs.length-1];
65                                 }
66                                 target_div.innerHTML +=
67                                         "<div id=\"" + new_new_div_name + "\">" +
68                                         "<div id=\"ctdl-newmsg-here\"></div>" +
69                                         "<div class=\"ctdl-forum-nav\" " +
70                                         "onClick=\"javascript:forum_readmessages('" + new_new_div_name + "', " + newgt + ", 9999999999);\">" +
71                                         "<i class=\"fa fa-arrow-circle-down\"></i>&nbsp;&nbsp;" +
72                                         _("Newer posts") + "&nbsp;&nbsp;<i class=\"fa fa-arrow-circle-down\"></div></div>" ;
73                         }
74
75                         // Now figure out where to scroll to after rendering.
76                         if (gt_msg > 0) {
77                                 scroll_to = msgs[0];
78                         }
79                         else if (lt_msg < 9999999999) {
80                                 scroll_to = msgs[msgs.length-1];
81                         }
82                         else if ( (logged_in) && (gt_msg == 0) && (lt_msg == 9999999999) ) {
83                                 scroll_to = msgs[msgs.length-1];
84                         }
85                         else {
86                                 scroll_to = msgs[0];            // FIXME this is too naive
87                         }
88
89                         // Render the individual messages in the divs
90                         forum_render_messages(msgs, msgs_div_name, scroll_to)
91                 }
92                 else {
93                         // if xhr fails, this will make the link reappear so the user can try again
94                         target_div.innerHTML = original_text;
95                 }
96         }
97         fetch_msg_list();
98
99         // make the nav buttons appear (post a new message, skip this room, goto next room)
100
101         document.getElementById("ctdl-newmsg-button").innerHTML = "<i class=\"fa fa-edit\"></i>" + _("Post message");
102         document.getElementById("ctdl-newmsg-button").style.display = "block";
103
104         document.getElementById("ctdl-skip-button").innerHTML = "<i class=\"fa fa-arrow-alt-circle-right\"></i>" + _("Skip this room");
105         document.getElementById("ctdl-skip-button").style.display = "block";
106
107         document.getElementById("ctdl-goto-button").innerHTML = "<i class=\"fa fa-arrow-circle-right\"></i>" + _("Goto next room");
108         document.getElementById("ctdl-goto-button").style.display = "block";
109 }
110
111
112 // Render a range of messages into the specified target div
113 function forum_render_messages(message_numbers, msgs_div_name, scroll_to) {
114
115         // Build an array of Promises and then wait for them all to resolve.
116         let num_msgs = message_numbers.length;
117         let msg_promises = Array.apply(null, Array(num_msgs));
118         for (i=0; i<num_msgs; ++i) {
119                 msg_promises[i] = fetch("/ctdl/r/" + escapeHTMLURI(current_room) + "/" + message_numbers[i] + "/json")
120                         .then(response => response.json())
121                         .catch((error) => {
122                                 response => null;
123                                 console.error('Error: ', error);
124                         })
125                 ;
126         }
127
128         // Here is the async function that waits for all the messages to be loaded, and then renders them.
129         fetch_msg_list = async() => {
130                 document.body.style.cursor = "wait";
131                 activate_loading_modal();
132                 await Promise.all(msg_promises);
133                 deactivate_loading_modal();
134                 document.body.style.cursor = "default";
135                 
136                 // At this point all of the Promises are resolved and we can render.
137                 // Note: "let" keeps "i" in scope even through the .then scope
138                 let scroll_to_div = null;
139                 for (let i=0; i<num_msgs; ++i) {
140                         msg_promises[i].then((one_message) => {
141                                 let new_msg_div = forum_render_one(one_message, null);
142                                 document.getElementById(msgs_div_name).append(new_msg_div);
143                                 if (message_numbers[i] == scroll_to) {
144                                         scroll_to_div = new_msg_div;
145                                 }
146                                 if (i == num_msgs - 1) {
147                                         scroll_to_div.scrollIntoView({behavior: "smooth", block: "start", inline: "nearest"});
148                                 }
149                         });
150                 }
151         }
152
153         fetch_msg_list();
154
155         // Make a note of the highest message number we saw, so we can mark it when we "Goto next room"
156         // (Compared to the text client, this is actually more like <A>bandon than <G>oto)
157         if ((num_msgs > 0) && (message_numbers[num_msgs-1] > last_seen)) {
158                 last_seen = message_numbers[num_msgs-1];
159         }
160 }
161
162
163 // Render a message.  Returns a div object.
164 function forum_render_one(msg, existing_div) {
165         let div = null;
166         if (existing_div != null) {                                             // If an existing div was supplied, render into it
167                 div = existing_div;
168         }
169         else {                                                                  // Otherwise, create a new one
170                 div = document.createElement("div");
171         }
172
173         mdiv = randomString();                                                  // Give the div a new name
174         div.id = mdiv;
175
176         try {
177                 outmsg =
178                   "<div class=\"ctdl-fmsg-wrapper\">"                           // begin message wrapper
179                 + render_userpic(msg.from)                                      // user avatar
180                 + "<div class=\"ctdl-fmsg-content\">"                           // begin content
181                 + "<div class=\"ctdl-msg-header\">"                             // begin header
182                 + "<span class=\"ctdl-msg-header-info\">"                       // begin header info on left side
183                 + render_msg_author(msg, views.VIEW_BBS)                        // author
184                 + "<span class=\"ctdl-msgdate\">"
185                 + string_timestamp(msg.time,0)
186                 + "</span>"                                                     // end msgdate
187                 + "</span>"                                                     // end header info on left side
188                 + "<span class=\"ctdl-msg-header-buttons\">"                    // begin buttons on right side
189         
190                 + "<span class=\"ctdl-msg-button\">"                            // Reply
191                 + "<a href=\"javascript:open_reply_box('"+mdiv+"',false,'"+msg.wefw+"','"+msg.msgn+"');\">"
192                 + "<i class=\"fa fa-reply\"></i> " 
193                 + _("Reply")
194                 + "</a></span>"
195         
196                 + "<span class=\"ctdl-msg-button\">"                            // ReplyQuoted
197                 + "<a href=\"javascript:open_reply_box('"+mdiv+"',true,'"+msg.wefw+"','"+msg.msgn+"');\">"
198                 + "<i class=\"fa fa-comment\"></i> " 
199                 + _("ReplyQuoted")
200                 + "</a></span>";
201         
202                 if (can_delete_messages) {
203                         outmsg +=
204                         "<span class=\"ctdl-msg-button\">"
205                         + "<a href=\"javascript:forum_delete_message('"+mdiv+"','"+msg.msgnum+"');\">"
206                         + "<i class=\"fa fa-trash\"></i> " 
207                         + _("Delete")
208                         + "</a></span>";
209                 }
210         
211                 outmsg +=
212                   "</span>";                                                    // end buttons on right side
213                 if (msg.subj) {
214                         outmsg +=
215                         "<br><span class=\"ctdl-msgsubject\">" + msg.subj + "</span>";
216                 }
217                 outmsg +=
218                   "</div>"                                                      // end header
219                 + "<div class=\"ctdl-msg-body\" id=\"" + mdiv + "_body\">"      // begin body
220                 + msg.text
221                 + "</div>"                                                      // end body
222                 + "</div>"                                                      // end content
223                 + "</div>"                                                      // end wrapper
224                 ;
225         }
226         catch(err) {
227                 outmsg = "<div class=\"ctdl-fmsg-wrapper\">" + err.message + "</div>";
228         }
229
230         div.innerHTML = outmsg;
231         return(div);
232 }
233
234
235 // Compose a references string using existing references plus the message being replied to
236 function compose_references(references, msgid) {
237         if (references.includes("@")) {
238                 refs = references + "|";
239         }
240         else {
241                 refs = "";
242         }
243         refs += msgid;
244
245         // If the resulting string is too big, we can trim it here
246         while (refs.length > 900) {
247                 r = refs.split("|");
248                 r.splice(1,1);          // remove the second element so we keep the root
249                 refs = r.join("|");
250         }
251         return refs;
252 }
253
254 // Delete a message.
255 // We don't bother checking for permission because the button only appears if we have permission,
256 // and even if someone hacks the client, the server will deny any unauthorized deletes.
257 function forum_delete_message(message_div, message_number) {
258         if (confirm(_("Delete this message?")) == true) {
259                 async_forum_delete_message = async() => {
260                         response = await fetch(
261                                 "/ctdl/r/" + escapeHTMLURI(current_room) + "/" + message_number,
262                                 { method: "DELETE" }
263                         );
264                         if (response.ok) {              // If the server accepted the delete, blank out the message div.
265                                 document.getElementById(message_div).outerHTML = "";
266                         }
267                 }
268                 async_forum_delete_message();
269         }
270 }
271
272
273 // Open a reply box directly below a specific message
274 function open_reply_box(parent_div, is_quoted, references, msgid) {
275         let new_div = document.createElement("div");
276         let new_div_name = randomString();
277         new_div.id = new_div_name;
278
279         document.getElementById(parent_div).append(new_div);
280
281         replybox =
282           "<div class=\"ctdl-fmsg-wrapper ctdl-msg-reply\">"            // begin message wrapper
283         + "<div class=\"ctdl-avatar\">"                                 // begin avatar
284         + "<img src=\"/ctdl/u/" + current_user + "/userpic\" width=\"32\" "
285         + "onerror=\"this.parentNode.innerHTML='&lt;i class=&quot;fa fa-user-circle fa-2x&quot;&gt;&lt;/i&gt; '\">"
286         + "</div>"                                                      // end avatar
287         + "<div class=\"ctdl-fmsg-content\">"                           // begin content
288         + "<div class=\"ctdl-msg-header\">"                             // begin header
289         + "<span class=\"ctdl-msg-header-info\">"                       // begin header info on left side
290         + "<span class=\"ctdl-username\">"
291         + current_user                                                  // user = me !
292         + "</span>"
293         + "<span class=\"ctdl-msgdate\">"
294         + string_timestamp((Date.now() / 1000),0)                       // the current date/time (temporary for display)
295         + "</span>"
296         + "</span>"                                                     // end header info on left side
297         + "<span class=\"ctdl-msg-header-buttons\">"                    // begin buttons on right side
298
299         + "<span class=\"ctdl-msg-button\">"                            // bold button
300         + "<a href=\"javascript:void(0)\" onclick=\"forum_format('bold')\">"
301         + "<i class=\"fa fa-bold fa-fw\"></i>" 
302         + "</a></span>"
303
304         + "<span class=\"ctdl-msg-button\">"                            // italic button
305         + "<a href=\"javascript:void(0)\" onclick=\"forum_format('italic')\">" 
306         + "<i class=\"fa fa-italic fa-fw\"></i>" 
307         + "</a></span>"
308
309         + "<span class=\"ctdl-msg-button\">"                            // list button
310         + "<a href=\"javascript:void(0)\" onclick=\"forum_format('insertunorderedlist')\">"
311         + "<i class=\"fa fa-list fa-fw\"></i>" 
312         + "</a></span>"
313
314         + "<span class=\"ctdl-msg-button\">"                            // link button
315         + "<a href=\"javascript:void(0)\" onclick=\"forum_display_urlbox()\">"
316         + "<i class=\"fa fa-link fa-fw\"></i>" 
317         + "</a></span>"
318
319         + "</span>";                                                    // end buttons on right side
320         //if (msg.subj) {
321                 //replybox +=
322                 //"<br><span id=\"ctdl-subject\" class=\"ctdl-msgsubject\">" + "FIXME subject" + "</span>";
323         //}
324         //else {                                                                // hidden filed for empty subject
325                 replybox += "<span id=\"ctdl-subject\" style=\"display:none\"></span>";
326         //}
327         replybox +=
328           "</div><br>"                                                  // end header
329
330         + "<span id=\"ctdl-replyreferences\" style=\"display:none\">"   // hidden field for references
331         + compose_references(references,msgid) + "</span>"
332                                                                         // begin body
333         + "<div class=\"ctdl-msg-body ctdl-forum-editor-body\" id=\"ctdl-editor-body\" contenteditable=\"true\">"
334         + "\n";                                                         // empty initial content
335
336         if (is_quoted) {
337                 replybox += "<br><blockquote>"
338                         + document.getElementById(parent_div+"_body").innerHTML
339                         + "</blockquote>";
340         }
341
342         replybox +=
343           "</div>"                                                      // end body
344
345         + "<div class=\"ctdl-msg-header\">"                             // begin footer
346         + "<span class=\"ctdl-msg-header-info\">"                       // begin footer info on left side
347         + "&nbsp;"                                                      // (nothing here for now)
348         + "</span>"                                                     // end footer info on left side
349         + "<span class=\"ctdl-msg-header-buttons\">"                    // begin buttons on right side
350
351         + "<span class=\"ctdl-msg-button\"><a href=\"javascript:forum_save_message('" + new_div_name + "');\">"
352         + "<i class=\"fa fa-check\" style=\"color:green\"></i> "        // save button
353         + _("Post message")
354         + "</a></span>"
355
356         + "<span class=\"ctdl-msg-button\"><a href=\"javascript:forum_cancel_post('" +  new_div_name + "');\">"
357         + "<i class=\"fa fa-trash\" style=\"color:red\"></i> "          // cancel button
358         + _("Cancel")
359         + "</a></span>"
360
361         + "</span>"                                                     // end buttons on right side
362         + "</div><br>"                                                  // end footer
363
364
365         + "</div>"                                                      // end content
366         + "</div>"                                                      // end wrapper
367
368         + "<div id=\"forum_url_entry_box\" class=\"ctdl-modal ctdl-forum-urlmodal\">"           // begin URL entry modal
369         + "     <div class=\"ctdl-modal-header\">" 
370         + "             <p>URL:</p>"
371         + "     </div>"
372         + "     <div class=\"ctdl-modal-main\">"
373         + "             <input id=\"ctdl-forum-urlbox\" placeholder=\"http://\" style=\"width:100%\">"
374         + "     </div>"
375         + "     <div class=\"ctdl-modal-footer\">"
376         + "             <p><span class=\"ctdl-msg-button\"><a href=\"javascript:forum_close_urlbox(true);\">"
377         + "                     <i class=\"fa fa-check\" style=\"color:green\"></i> "
378         +                       _("Save")
379         +               "</a></span>"
380         + "             <span class=\"ctdl-msg-button\"><a href=\"javascript:forum_close_urlbox(false);\">"
381         + "                     <i class=\"fa fa-trash\" style=\"color:red\"></i> "
382         +                       _("Cancel")
383         +               "</a></span></p>"
384         + "     </div>"
385         + "     <input id=\"forum_selection_start\" style=\"display:none\"></input>"    // hidden fields
386         + "     <input id=\"forum_selection_end\" style=\"display:none\"></input>"      // to store selection range
387         + "</div>"                                                      // end URL entry modal
388         ;
389
390         document.getElementById(new_div_name).innerHTML = replybox;
391         document.getElementById(new_div_name).scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"});
392
393         // These actions must happen *after* the initial render loop completes.
394         setTimeout(function() {
395                 var tag = document.getElementById("ctdl-editor-body");
396                 tag.focus();                                            // sets the focus
397                 window.getSelection().collapse(tag.firstChild, 0);      // positions the cursor
398         }, 0);
399 }
400
401
402 // Abort a message post (it simply destroys the div)
403 function forum_cancel_post(div_name) {
404         document.getElementById(div_name).outerHTML = "";               // make it cease to exist
405 }
406
407
408 // Save the posted message to the server
409 function forum_save_message(editor_div_name) {
410
411         document.body.style.cursor = "wait";
412         wefw = (document.getElementById("ctdl-replyreferences").innerHTML).replaceAll("|","!"); // references (if present)
413         subj = document.getElementById("ctdl-subject").innerHTML;                               // subject (if present)
414
415         url = "/ctdl/r/" + escapeHTMLURI(current_room)
416                 + "/dummy_name_for_new_message"
417                 + "?wefw=" + wefw
418                 + "&subj=" + subj
419         boundary = randomString();
420         body_text =
421                 "--" + boundary + "\r\n"
422                 + "Content-type: text/html\r\n"
423                 + "Content-transfer-encoding: quoted-printable\r\n"
424                 + "\r\n"
425                 + quoted_printable_encode(
426                         "<html><body>" + document.getElementById("ctdl-editor-body").innerHTML + "</body></html>"
427                 ) + "\r\n"
428                 + "--" + boundary + "--\r\n"
429         ;
430
431         var request = new XMLHttpRequest();
432         request.open("PUT", url, true);
433         request.setRequestHeader("Content-type", "multipart/mixed; boundary=\"" + boundary + "\"");
434         request.onreadystatechange = function() {
435                 if (request.readyState == 4) {
436                         document.body.style.cursor = "default";
437                         if (Math.trunc(request.status / 100) == 2) {
438                                 headers = request.getAllResponseHeaders().split("\n");
439                                 for (var i in headers) {
440                                         if (headers[i].startsWith("etag: ")) {
441                                                 new_msg_num = headers[i].split(" ")[1];
442                                         }
443                                 }
444
445                                 // After saving the message, load it back from the server and replace the editor div with it.
446                                 replace_editor_with_final_message = async() => {
447                                         response = await fetch("/ctdl/r/" + escapeHTMLURI(current_room) + "/" + new_msg_num + "/json");
448                                         if (response.ok) {
449                                                 newly_posted_message = await(response.json());
450                                                 forum_render_one(newly_posted_message, document.getElementById(editor_div_name));
451                                         }
452                                 }
453                                 replace_editor_with_final_message();
454
455                         }
456                         else {
457                                 error_message = request.responseText;
458                                 if (error_message.length == 0) {
459                                         error_message = _("An error has occurred.");
460                                 }
461                                 alert(error_message);                                           // editor remains open
462                         }
463                 }
464         };
465         request.send(body_text);
466 }
467
468
469 // Bold, italics, etc.
470 function forum_format(command, value) {
471         document.execCommand(command, false, value);
472 }
473
474
475 // Make the URL entry box appear.
476 // When the user clicks into the URL box it will make the previous focus disappear, so we have to save it.
477 function forum_display_urlbox() {
478         document.getElementById("forum_selection_start").value = window.getSelection().anchorOffset;
479         document.getElementById("forum_selection_end").value = window.getSelection().focusOffset;
480         document.getElementById("forum_url_entry_box").style.display = "block";
481 }
482
483
484 // When the URL box is closed, this gets called.  do_save is true for Save, false for Cancel.
485 function forum_close_urlbox(do_save) {
486         if (do_save) {
487                 var tag = document.getElementById("ctdl-editor-body");
488                 var start_replace = document.getElementById("forum_selection_start").value;     // use saved selection range
489                 var end_replace = document.getElementById("forum_selection_end").value;
490                 new_text = tag.innerHTML.substring(0, start_replace)
491                         + "<a href=\"" + document.getElementById("ctdl-forum-urlbox").value + "\">"
492                         + tag.innerHTML.substring(start_replace, end_replace)
493                         + "</a>"
494                         + tag.innerHTML.substring(end_replace);
495                 tag.innerHTML = new_text;
496         }
497         document.getElementById("ctdl-forum-urlbox").value = "";                                // clear url box for next time
498         document.getElementById("forum_url_entry_box").style.display = "none";
499 }
500
501
502 // User has clicked the "Post message" button.  This is roughly the same as "reply" except there is no parent message.
503 function forum_entmsg() {
504         open_reply_box("ctdl-newmsg-here", false, "", "");
505 }
506
507
508 // RENDERER FOR THIS VIEW
509 function view_render_forums() {
510         document.getElementById("ctdl-main").innerHTML = "<div id=\"ctdl-mrp\" class=\"ctdl-forum-reading-pane\"></div>";
511         forum_readmessages("ctdl-mrp", 0, 9999999999);
512 }