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