colorful rat Ratfactor.com > Dave's Repos

famsite

A super tiny social site for families
git clone http://ratfactor.com/repos/famsite/famsite.git

famsite/fam.js

Download raw file: fam.js

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