1 var THUMB_MAX = 200; // image max dimensions in px
     2 var times = []; // Populated by get_times and start/stop actions.
     3 var drawto = document.getElementById('post-area');
     4 var last_touched = 0;
     5 var thumbnail_blob = null;
     6 var submitting = false;
     7 var get_post_controller; // set in get_new_posts()
     8 var editing_post = null;
     9 
    10 
    11 // ============================================================================
    12 
    13 var daynames = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
    14 var curdate; // For displaying date separators
    15 
    16 function draw(){
    17     curdate = (new Date()).getDate(); // start with today
    18 
    19     var attach_label = thumbnail_blob ? 'Attach a different image' : 'Attach an image';
    20 
    21     RV.render(drawto, [
    22         ['form', {rvid:'new_post_form', inert:submitting},
    23             ['input', {name:'parent', type:'hidden', value:null}],
    24             ['.postbox',
    25                 ['textarea', {name:'txt',rvid:'post_txt',
    26                         onpaste:onpaste_text, onkeyup:onkeyup_text,
    27                     }],
    28                 ['div', {id:'image-preview'}, false],
    29                 ['.control-row',
    30                     ['a', {href:'#', onclick:upload_image}, attach_label],
    31                     ['a', {href:'#', onclick:emoji('post_txt')},
    32                         ['<span>😀 Add Emoji!</span>']],
    33                     ['a', {href:'#', onclick:spoiler('post_txt')},
    34                         ['<span>🥷 Add spoiler!</span>']],
    35                     ['input', {type:'file', rvid:'imagef', name:'image',
    36                         accept:"image/*", onchange:make_thumb}],
    37                     ['div',
    38                         ['button', {rvid:'postbtn', class:(submitting?'whee':''),
    39                             onclick:submit_new_post,}, 'Post!'],
    40                     ],
    41                 ],
    42             ],
    43         ],
    44         ['.posts', draw_posts()],
    45     ]);
    46 }
    47 
    48 function emoji(insert_to){
    49     return function(e){
    50         e.preventDefault();
    51         FC.attach_popup(e.target, function(emoji){
    52             FC.insert(RV.id[insert_to], emoji);
    53         });
    54     };
    55 }
    56 
    57 function spoiler(insert_to){
    58     return function(e){
    59         e.preventDefault();
    60 
    61         var st = "";
    62         var el_popup = make_popup(e.target,{
    63             class: 'spoiler-pop',
    64             width: '400px',
    65             offset_x: -100,
    66             offset_y: -10,
    67         });
    68         var input_el = null;
    69         var button_el = null;
    70 
    71         RV.render(el_popup, [
    72             ["button.cancel", {onclick:function(){
    73                 el_popup.remove();
    74             }}, "Cancel"],
    75             ["input", {placeholder:"Spoiler here...",
    76                 oninput:function(e){ st = e.target.value; },
    77                 oncreate:function(el){input_el = el;},
    78                 onkeypress:function(e){
    79                     if(e.key === 'Enter'){
    80                         e.preventDefault();
    81                         button_el.click();
    82                     }
    83                 },
    84             }],
    85             ["button.add", {
    86                 onclick:function(){
    87                     pasteinto(RV.id[insert_to], `<spoiler>${st}</spoiler>`);
    88                     el_popup.remove();
    89                 },
    90                 oncreate:function(el){button_el = el;},
    91             }, "Add"],
    92         ]);
    93 
    94         // Put focus in input immediately
    95         input_el.focus();
    96     };
    97 }
    98 
    99 function make_popup(elem, props){
   100     // Put it an arbitrary amount over the click target
   101     var x = elem.getBoundingClientRect().left + window.scrollX + props.offset_x;
   102     var y = elem.getBoundingClientRect().bottom + window.scrollY + props.offset_y;
   103     var pop = document.createElement("div");
   104     pop.style.left = x+'px';
   105     pop.style.top = y+'px';
   106     pop.style.width = props.width;
   107     pop.classList.add('popup');
   108     pop.classList.add(props.class);
   109     document.body.appendChild(pop);
   110     return pop;
   111 }
   112 
   113 function pasteinto(elem, txt){
   114     var start = elem.selectionStart;
   115     var end = elem.selectionEnd;
   116     elem.setRangeText(txt, start, end, 'end');
   117     elem.focus();
   118 }
   119 
   120 var match_wordplay = /WordPlay.com\s+Daily Puzzle #/;
   121 var match_connections= /Connections.*Puzzle #/s;
   122 
   123 // helper for draw_posts()
   124 function make_day(daynum, dayofweek){
   125     return {
   126         daynum: daynum,
   127         dayname: daynames[dayofweek],
   128         special1: [],
   129         special2: [],
   130         posts: []
   131     };
   132 }
   133 
   134 function draw_posts(){
   135     // Put posts into day and type buckets
   136     var days = [];
   137     var d; // current day object
   138 
   139     posts.forEach(function(post){
   140         // Update the last post we've seen so we know to check for
   141         // posts newer than this.
   142         if(post.touched > last_touched){
   143             last_touched = post.touched;
   144         }
   145 
   146         // Get the post's day of the month
   147         post.dateobj = new Date(post.posted * 1000);
   148         post.daynum = post.dateobj.getDate(); // day of the month
   149         post.time = post.dateobj.toLocaleTimeString('en-GB'); // 24-hour time
   150         if(!d || d.daynum != post.daynum){
   151             // Make new day object and add it to the list
   152             d = make_day(post.daynum, post.dateobj.getDay());
   153             days.push(d);
   154         }
   155 
   156         // Detect post type and categorize - special or regular post
   157         if(match_wordplay.test(post.txt)){
   158             d.special1.push(post);
   159         }
   160         else if(match_connections.test(post.txt)){
   161             d.special2.push(post);
   162         }
   163         else{
   164             d.posts.push(post);
   165         }
   166     });
   167 
   168     // Now draw!
   169     return days.map(function(day){
   170         return [
   171             ['h2.day', day.dayname],
   172             day.posts.map(draw_post),
   173             draw_special(2, day),
   174             draw_special(1, day),
   175         ]
   176     });
   177 }
   178 
   179 function draw_special(num, day){
   180     if(day['special'+num].length < 1){
   181         return null;
   182     }
   183     // since we read left-to-right, put oldest first
   184     day['special'+num].sort(function(a,b){
   185         return a.posted - b.posted;
   186     });
   187     return [".special", {class:'special'+num},
   188         day['special'+num].map(draw_post)];
   189 }
   190 
   191 // Regexes to match simple syntax elements. Note that lookahead and behind
   192 // assertions are now supported in browsers.
   193 var rx_spoil = /<spoiler>([^<]*)<\/spoiler>/g;
   194 var rx_code = /`([^`]+)`/g;
   195 var rx_bold = /(?<=^|\s)\*([^*]+)\*(?=\s|$)/g;
   196 var rx_ital = /(?<=^|\s)_([^_]+)_(?=\s|$)/g;
   197 var rx_url = /https?:\/\/(\S+)/g;
   198 
   199 function draw_post(post){
   200     // The post body is either the txt field or the edit box
   201     var post_body;
   202 
   203     if(editing_post === post.rowid){
   204         post_body = draw_edit_post(post);
   205     }
   206     else
   207     {
   208 
   209         // Break post body into lines for processing
   210         var lines = post.txt.split(/\r?\n/);
   211         lines = lines.map(function(line){
   212             // Using raw HTML here because it's much easier to do.
   213             line = line.replace(rx_spoil, '<span class="spoiler">$1</span>');
   214             line = line.replace(rx_code, '<code>$1</code>');
   215             line = line.replace(rx_bold, '<b>$1</b>');
   216             line = line.replace(rx_ital, '<i>$1</i>');
   217             line = line.replace(rx_url, '<a href="$1" target="_blank">$1</a>');
   218 
   219             // Ensure blank lines render by giving them a br
   220             if(line.length === 0){
   221                 line = '<br>';
   222             }
   223 
   224             // Each line goes in a div. Use HTML tag to force line to
   225             // be rendered as a raw HTML string.
   226             return ['<div>'+line+'</div>'];
   227         });
   228 
   229         post_body = ['.txt', lines];
   230     }
   231 
   232     var u = users[post.user];
   233     if(!u){ u = {name:'_'}; }
   234     return [
   235         ['.post', {'id':'post-'+post.rowid},
   236             ['.info',
   237                 ['img.avatar', {src:'/avatars/'+post.user+'.png'}],
   238                 ['div',
   239                     ['b', u.name],
   240                     ['br'],
   241                     ['i', post.time],
   242                     draw_add_reaction_link(post),
   243                     draw_edit_link(post),
   244                 ],
   245             ],
   246             post_body,
   247             draw_post_image(post),
   248             draw_post_reactions(post),
   249         ],
   250     ];
   251 }
   252 
   253 function draw_post_reactions(post){
   254     var rs = post.reactions;
   255     if(rs.length < 1){ return null; }
   256 
   257     return ['.reactions', rs.map(function(r){
   258         return ['div', 
   259             ['span.emoji', r.emoji],
   260             ['span.user', users[r.user].name + (r.txt.length > 0 ? ':' : '')],
   261             ['span.txt', r.txt],
   262         ];
   263     })];
   264 }
   265 
   266 function draw_post_image(post){
   267     if(!post.filename){ return null; }
   268     return ['.image',
   269         ['a', {href:'/uploads/'+post.filename, target: '_blank'},
   270             ['img', {src:'/uploads/tn_'+post.filename}],
   271         ],
   272         ['br'],
   273         ['i.small', "(Click image to view full size)"],
   274     ];
   275 }
   276 
   277 function draw_edit_link(post){
   278     if(post.user !== my_id) return null;
   279     return ['a.edit-link',
   280         {href:'#',onclick:edit_post_click(post.rowid)}, "Edit post"];
   281 }
   282 
   283 function draw_add_reaction_link(post){
   284     var picked_emoji = '😀';
   285     var st = "";
   286     var el_popup = null;
   287     var input_el = null;
   288     var pick_el = null;
   289 
   290     function emoji_callback(emoji){
   291         picked_emoji = emoji;
   292         FC.close();
   293         draw_self();
   294     }
   295 
   296     function draw_self(){
   297         RV.render(el_popup, [
   298             ["button.cancel", {onclick:function(){
   299                 el_popup.remove();
   300             }}, "Cancel"],
   301             ["button.pick", {
   302                     onclick:function(e){
   303                         FC.attach_popup(e.target, emoji_callback);
   304                     },
   305                     oncreate:function(el){pick_el = el;},
   306                 },
   307                 "Pick ",
   308                 [`<span class="picked-emoji">${picked_emoji}</span>`],
   309             ],
   310             ["input", {placeholder:"Note (optional)",
   311                 oninput:function(e){ st = e.target.value; },
   312                 oncreate:function(el){input_el = el;},
   313                 onkeypress:function(e){
   314                     if(e.key === 'Enter'){
   315                         e.preventDefault();
   316                         RV.id.reaction_add_btn.click();
   317                     }
   318                 },
   319             }],
   320             ["button.add", {
   321                 onclick:function(){
   322                     submit_reaction(post, picked_emoji, input_el.value);
   323                     //el_popup.remove();
   324                 },
   325                 rvid:'reaction_add_btn',
   326             }, "Add"],
   327         ]);
   328     }
   329 
   330     return ['a.add_reaction', {href:'#', onclick:function(e){
   331             document.querySelectorAll('.reaction-pop').forEach(function(el){
   332                 el.remove(); // Kill 'em all. Easier to deal with just one.
   333             });
   334             e.preventDefault();
   335 
   336             el_popup = make_popup(e.target,{
   337                 class: 'reaction-pop',
   338                 width: '520px',
   339                 offset_x: -60,
   340                 offset_y: -60,
   341             });
   342 
   343             draw_self();
   344             FC.attach_popup(e.target, emoji_callback);
   345         }},
   346         ['span.label', 'Add reaction']
   347     ];
   348 }
   349 
   350 function edit_post_click(post_id){
   351     return function(e){
   352         editing_post = post_id;
   353         draw();
   354         e.preventDefault();
   355     };
   356 }
   357 
   358 function draw_edit_post(post){
   359     return ['form', {rvid:'edit_post_form', inert:submitting},
   360         ['input', {type:'hidden',name:'rowid',value:post.rowid}],
   361         ['.postbox',
   362             ['textarea', {
   363                 name:'txt', rvid:'edit_post_txt',
   364                 oncreate:function(elem){
   365                     // Resize textarea to fit content to edit
   366                     if(elem.scrollHeight > elem.clientHeight){
   367                         elem.style.height = elem.scrollHeight + 'px';
   368                     }
   369                 }}, post.txt
   370             ],
   371             ['.control-row',
   372                 ['a', {href:'#', onclick:emoji('edit_post_txt')},
   373                         ['<span>😀 Add Emoji!</span>']],
   374                 ['a', {href:'#', onclick:spoiler('edit_post_txt')},
   375                     ['<span>🥷 Add spoiler!</span>']],
   376                 ['div.buttons',
   377                     ['button.cancel', {rvid:'posteditbtn', class:(submitting?'whee':''),
   378                         onclick:cancel_edit_post}, 'Cancel'],
   379                     ['button', {rvid:'posteditbtn', class:(submitting?'whee':''),
   380                         onclick:submit_edit_post,}, 'Submit Edit!'],
   381                 ],
   382             ],
   383         ],
   384     ];
   385 }
   386 
   387 function upload_image(e){
   388     e.preventDefault();
   389     RV.id.imagef.click();
   390 }
   391 
   392 function make_thumb(){
   393     var preview_thumb = document.createElement('img');
   394     var preview_area = document.getElementById('image-preview');
   395     preview_area.replaceChildren();
   396     preview_area.appendChild(preview_thumb);
   397 
   398     var file = this.files[0];
   399     var reader = new FileReader();
   400     var img = new Image();
   401     reader.onload = function(e){
   402         // STEP 2: load image
   403         img.src = e.src = e.target.result;
   404     };
   405     img.onload = function(e){
   406         // STEP 3: resize on canvas, preview
   407         var biggest_dim =  Math.max(img.width, img.height);
   408         var scale = THUMB_MAX < biggest_dim ? THUMB_MAX / biggest_dim : 1;
   409         var w = img.width * scale;
   410         var h = img.height * scale;
   411 
   412         // Make a canvas element for the resizing
   413         var canvas = document.createElement('canvas');
   414         canvas.width = w; canvas.height = h;
   415         var context = canvas.getContext('2d');
   416         context.drawImage(img, 0, 0, w, h);
   417         preview_thumb.src = canvas.toDataURL(file.type);
   418 
   419         // Save blob data to append to form for uploading later
   420         canvas.toBlob(function(b){
   421             thumbnail_blob = b;
   422             draw();
   423         }, file.type);
   424     };
   425     // STEP 1: read file
   426     reader.readAsDataURL(file);
   427 }
   428 
   429 function onpaste_text(e){
   430     e.preventDefault();
   431 
   432     // Get pasted text and modify it
   433     var pasted = event.clipboardData.getData("text");
   434 
   435     // Strip end of Wordplay text. NOTE the 's' flag matches over newlines!
   436     pasted = pasted.replace(/Play the game.*#wordplay/s, '');
   437 
   438     // Add newlines to Connections paste
   439     if(pasted.match(/Connections.*Puzzle/s)){
   440         pasted += "\n\n";
   441     }
   442 
   443     // Manually perform the "paste" on the current text and update area.
   444     var prev_txt = RV.id.post_txt.value;
   445     var start = RV.id.post_txt.selectionStart;
   446     var end = RV.id.post_txt.selectionEnd;
   447     RV.id.post_txt.value = prev_txt.slice(0, start) + pasted + prev_txt.slice(end);
   448 }
   449 
   450 function onkeyup_text(){
   451     // After typing or pasting (via keyboard), resize textarea as needed
   452     var t = RV.id.post_txt;
   453     if(t.scrollHeight > t.clientHeight){
   454         t.style.height = t.scrollHeight + 'px';
   455     }
   456 }
   457 
   458 function upload_progress(e){
   459     var p = (e.loaded / e.total * 100).toFixed(1);
   460     RV.id.postbtn.textContent = "Posting... "+p+"%";
   461 }
   462 function upload_done(e){
   463     submitting = false;
   464     if(e.target.status !== 200){
   465         alert("Error! Return status was: "+e.target.status);
   466         return;
   467     }
   468     var posted = JSON.parse(e.target.response);
   469     add_posts(posted);
   470     RV.id.post_txt.value = ""; // clear textarea
   471     RV.id.imagef.value = ""; // clear file input
   472     thumbnail_blob = null;
   473     document.getElementById('image-preview').replaceChildren();
   474     draw();
   475 }
   476 
   477 function submit_new_post(e){
   478     e.preventDefault();
   479     if(RV.id.post_txt.value == '' && !thumbnail_blob){
   480         RV.id.postbtn.textContent = "Nothing to post.";
   481         setTimeout(function(){
   482             RV.id.postbtn.textContent = 'Post!';
   483         },1000);
   484         return;
   485     }
   486     submitting = true;
   487     draw();
   488     RV.id.postbtn.textContent = "Posting...";
   489     var data = new FormData(RV.id.new_post_form);
   490     if(thumbnail_blob){ data.append('thumb', thumbnail_blob); }
   491     var xhr = new XMLHttpRequest();
   492     xhr.onload = upload_done;
   493     xhr.upload.addEventListener('progress', upload_progress);
   494     xhr.open('POST', "fam.php?r=posts", true);
   495     xhr.onerror = function(){alert('Network error?');}
   496     xhr.send(data);
   497 }
   498 
   499 // Edit Post
   500 // ===========================================================================
   501 function edit_progress(e){
   502     RV.id.posteditbtn.textContent = "Submitting "+e.loaded+"/"+e.total+"...";
   503 }
   504 function edit_done(e){
   505     submitting = false;
   506     if(e.target.status !== 200){
   507         alert("Error! Return status was: "+e.target.status);
   508         return;
   509     }
   510     var posted = JSON.parse(e.target.response);
   511     replace_posts(posted);
   512     editing_post = null;
   513     draw();
   514 }
   515 
   516 function submit_edit_post(e){
   517     e.preventDefault();
   518     if(RV.id.edit_post_txt.value == '' && !thumbnail_blob){
   519         RV.id.posteditbtn.textContent = "Nothing to post.";
   520         setTimeout(function(){
   521             RV.id.posteditbtn.textContent = 'Submit Edit!';
   522         },1000);
   523         return;
   524     }
   525     submitting = true;
   526     draw();
   527     RV.id.posteditbtn.textContent = "Submitting...";
   528     var data = new FormData(RV.id.edit_post_form);
   529     var xhr = new XMLHttpRequest();
   530     xhr.onload = edit_done;
   531     xhr.upload.addEventListener('progress', edit_progress);
   532     xhr.open('post', "fam.php?r=post", true);
   533     xhr.onerror = function(){alert('Network error?');}
   534     xhr.send(data);
   535 }
   536 
   537 function cancel_edit_post(e){
   538     e.preventDefault();
   539     editing_post = null;
   540     draw();
   541 }
   542 
   543 // Add reaction
   544 // ===========================================================================
   545 function reaction_done(e){
   546     document.querySelector('.reaction-pop').remove();
   547     if(e.target.status !== 200){
   548         alert("Error! Return status was: "+e.target.status);
   549         return;
   550     }
   551     // We get back the whole post with reactions for redrawing!
   552     var posted = JSON.parse(e.target.response);
   553     replace_posts(posted);
   554     draw();
   555 }
   556 
   557 function submit_reaction(post, emoji, txt){
   558     RV.id.reaction_add_btn.textContent = '...';
   559     RV.id.reaction_add_btn.classList.add('whee');
   560     RV.id.reaction_add_btn.inert = true;
   561     var data = new FormData();
   562     data.append('post', post.rowid);
   563     data.append('emoji', emoji);
   564     data.append('txt', txt);
   565     var xhr = new XMLHttpRequest();
   566     xhr.onload = reaction_done;
   567     xhr.open('post', "fam.php?r=reaction", true);
   568     xhr.onerror = function(){alert('Network error?');}
   569     xhr.send(data);
   570 }
   571 
   572 // ===========================================================================
   573 function add_posts(list){
   574     var prev_rowid = null;
   575 
   576     // Parse incoming JSON reactions
   577     list.forEach(function(p){
   578         p.reactions = JSON.parse(p.reactions);
   579     });
   580 
   581     // Add one or more posts to the list
   582     posts = posts.concat(list)
   583         // Sort by rowid, or touched recentness
   584         .sort(function(a,b){
   585             if(b.rowid === a.rowid){
   586                 return b.touched - a.touched;
   587             }
   588             return b.rowid - a.rowid;
   589         })
   590         // Duplicate rows are now consecutive, filter them out
   591         .filter(function(post, i, list){
   592             var dup = prev_rowid === post.rowid
   593             prev_rowid = post.rowid;
   594             return !dup;
   595         });
   596 }
   597 
   598 function replace_posts(replacement_posts){
   599     var replacing_ids = replacement_posts.map(function(p){
   600         return p.rowid;
   601     });
   602     // Remove a post for replacement
   603     posts = posts.filter(function(post, i, list){
   604         return !replacing_ids.includes(post.rowid);
   605     });
   606     // Now re-add them
   607     add_posts(replacement_posts);
   608 }
   609 
   610 async function get_new_posts() {
   611     // to test with a delay, ex.: &sleep=4
   612     var url = "fam.php?r=posts&after=" + last_touched;
   613 
   614     get_post_controller = new AbortController();
   615     try{
   616         var response = await fetch(url, {
   617             signal: get_post_controller.signal,
   618         });
   619     }
   620     catch(error) {
   621         if(error.name === 'AbortError'){
   622             console.log("Info: Fetch aborted due to new post being posted.");
   623             return;
   624         }
   625         throw error; // not the AbortError! Re-throw this.
   626     }
   627 
   628     if (!response.ok) {
   629         alert('New post request failure: ' + response.status);
   630         return;
   631     }
   632 
   633     // Append any new items to the list and re-draw
   634     var json = await response.json();
   635     if(json.length > 0){
   636         add_posts(json);
   637 
   638         // Pause all drawing while we're editing an existing post.
   639         if(editing_post){
   640             return;
   641         }
   642 
   643         draw();
   644     }
   645 }
   646 
   647 // Initial draw and start polling.
   648 draw();
   649 setInterval(get_new_posts, 5000); // 5 seconds