damus.js (43513B)
1 2 let DSTATE 3 4 function uuidv4() { 5 return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => 6 (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 7 ); 8 } 9 10 function insert_event_sorted(evs, new_ev) { 11 for (let i = 0; i < evs.length; i++) { 12 const ev = evs[i] 13 14 if (new_ev.id === ev.id) { 15 return false 16 } 17 18 if (new_ev.created_at > ev.created_at) { 19 evs.splice(i, 0, new_ev) 20 return true 21 } 22 } 23 24 evs.push(new_ev) 25 return true 26 } 27 28 function init_contacts() { 29 return { 30 event: null, 31 friends: new Set(), 32 friend_of_friends: new Set(), 33 } 34 } 35 36 function init_home_model() { 37 return { 38 done_init: false, 39 loading: true, 40 notifications: 0, 41 rendered: {}, 42 all_events: {}, 43 expanded: new Set(), 44 reactions_to: {}, 45 events: [], 46 chatrooms: {}, 47 deletions: {}, 48 cw_open: {}, 49 deleted: {}, 50 profiles: {}, 51 profile_events: {}, 52 last_event_of_kind: {}, 53 contacts: init_contacts() 54 } 55 } 56 57 const BOOTSTRAP_RELAYS = [ 58 "wss://relay.damus.io", 59 "wss://nostr-relay.wlvs.space", 60 "wss://nostr-pub.wellorder.net" 61 ] 62 63 function update_favicon(path) 64 { 65 let link = document.querySelector("link[rel~='icon']"); 66 const head = document.getElementsByTagName('head')[0] 67 68 if (!link) { 69 link = document.createElement('link'); 70 link.rel = 'icon'; 71 head.appendChild(link); 72 } 73 74 link.href = path; 75 } 76 77 function update_title(model) { 78 if (document.visibilityState === 'visible') 79 model.notifications = 0 80 if (model.notifications === 0) { 81 document.title = "Damus" 82 update_favicon("img/damus.svg") 83 } else { 84 document.title = `(${model.notifications}) Damus` 85 update_favicon("img/damus_notif.svg") 86 } 87 } 88 89 function notice_chatroom(state, id) 90 { 91 if (!state.chatrooms[id]) 92 state.chatrooms[id] = {} 93 } 94 95 async function damus_web_init() 96 { 97 const model = init_home_model() 98 DSTATE = model 99 model.pubkey = await get_pubkey() 100 if (!model.pubkey) 101 return 102 const {RelayPool} = nostrjs 103 const pool = RelayPool(BOOTSTRAP_RELAYS) 104 const now = (new Date().getTime()) / 1000 105 106 const ids = { 107 comments: "comments",//uuidv4(), 108 profiles: "profiles",//uuidv4(), 109 account: "account",//uuidv4(), 110 home: "home",//uuidv4(), 111 contacts: "contacts",//uuidv4(), 112 notifications: "notifications",//uuidv4(), 113 dms: "dms",//uuidv4(), 114 } 115 116 model.pool = pool 117 model.view_el = document.querySelector("#view") 118 redraw_home_view(model) 119 120 document.addEventListener('visibilitychange', () => { 121 update_title(model) 122 }) 123 124 pool.on('open', (relay) => { 125 //let authors = followers 126 // TODO: fetch contact list 127 log_debug("relay connected", relay.url) 128 129 if (!model.done_init) { 130 model.loading = false 131 132 send_initial_filters(ids.account, model.pubkey, relay) 133 } else { 134 send_home_filters(ids, model, relay) 135 } 136 //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) 137 }); 138 139 pool.on('event', (relay, sub_id, ev) => { 140 handle_home_event(ids, model, relay, sub_id, ev) 141 }) 142 143 pool.on('eose', async (relay, sub_id) => { 144 if (sub_id === ids.home) { 145 handle_comments_loaded(ids.profiles, model, relay) 146 } else if (sub_id === ids.profiles) { 147 handle_profiles_loaded(ids.profiles, model, relay) 148 } 149 }) 150 151 return pool 152 } 153 154 function process_reaction_event(model, ev) 155 { 156 if (!is_valid_reaction_content(ev.content)) 157 return 158 159 let last = {} 160 161 for (const tag of ev.tags) { 162 if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) 163 last[tag[0]] = tag[1] 164 } 165 166 if (last.e) { 167 model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() 168 model.reactions_to[last.e].add(ev.id) 169 } 170 } 171 172 function process_chatroom_event(model, ev) 173 { 174 try { 175 model.chatrooms[ev.id] = JSON.parse(ev.content) 176 } catch (err) { 177 log_debug("error processing chatroom creation event", ev, err) 178 } 179 } 180 181 function process_json_content(ev) 182 { 183 try { 184 ev.json_content = JSON.parse(ev.content) 185 } catch(e) { 186 log_debug("error parsing json content for", ev) 187 } 188 } 189 190 function process_deletion_event(model, ev) 191 { 192 for (const tag of ev.tags) { 193 if (tag.length >= 2 && tag[0] === "e") { 194 const evid = tag[1] 195 196 // we've already recorded this one as a valid deleted event 197 // we can just ignore it 198 if (model.deleted[evid]) 199 continue 200 201 let ds = model.deletions[evid] = (model.deletions[evid] || new Set()) 202 203 // add the deletion event id to the deletion set of this event 204 // we will use this to determine if this event is valid later in 205 // case we don't have the deleted event yet. 206 ds.add(ev.id) 207 } 208 } 209 } 210 211 function is_deleted(model, evid) 212 { 213 // we've already know it's deleted 214 if (model.deleted[evid]) 215 return model.deleted[evid] 216 217 const ev = model.all_events[evid] 218 if (!ev) 219 return false 220 221 // all deletion events 222 const ds = model.deletions[ev.id] 223 if (!ds) 224 return false 225 226 // find valid deletion events 227 for (const id of ds.keys()) { 228 const d_ev = model.all_events[id] 229 if (!d_ev) 230 continue 231 232 // only allow deletes from the user who created it 233 if (d_ev.pubkey === ev.pubkey) { 234 model.deleted[ev.id] = d_ev 235 log_debug("received deletion for", ev) 236 // clean up deletion data that we don't need anymore 237 delete model.deletions[ev.id] 238 return true 239 } else { 240 log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) 241 } 242 } 243 244 return false 245 } 246 247 function process_event(model, ev) 248 { 249 ev.refs = determine_event_refs(ev.tags) 250 const notified = was_pubkey_notified(model.pubkey, ev) 251 ev.notified = notified 252 253 if (ev.kind === 7) 254 process_reaction_event(model, ev) 255 else if (ev.kind === 42 && ev.refs && ev.refs.root) 256 notice_chatroom(model, ev.refs.root) 257 else if (ev.kind === 40) 258 process_chatroom_event(model, ev) 259 else if (ev.kind === 6) 260 process_json_content(ev) 261 else if (ev.kind === 5) 262 process_deletion_event(model, ev) 263 264 const last_notified = get_local_state('last_notified_date') 265 if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { 266 set_local_state('last_notified_date', new Date().getTime()) 267 model.notifications++ 268 update_title(model) 269 } 270 } 271 272 function was_pubkey_notified(pubkey, ev) 273 { 274 if (!(ev.kind === 1 || ev.kind === 42)) 275 return false 276 277 if (ev.pubkey === pubkey) 278 return false 279 280 for (const tag of ev.tags) { 281 if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) 282 return true 283 } 284 285 return false 286 } 287 288 function should_add_to_home(ev) 289 { 290 return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 291 } 292 293 let rerender_home_timer 294 function handle_home_event(ids, model, relay, sub_id, ev) { 295 model.all_events[ev.id] = ev 296 process_event(model, ev) 297 298 switch (sub_id) { 299 case ids.home: 300 if (should_add_to_home(ev)) 301 insert_event_sorted(model.events, ev) 302 303 if (model.realtime) { 304 if (rerender_home_timer) 305 clearTimeout(rerender_home_timer) 306 rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) 307 } 308 break; 309 case ids.account: 310 switch (ev.kind) { 311 case 3: 312 model.loading = false 313 process_contact_event(model, ev) 314 model.done_init = true 315 model.pool.unsubscribe(ids.account, [relay]) 316 break 317 case 0: 318 handle_profile_event(model, ev) 319 break 320 } 321 case ids.profiles: 322 try { 323 model.profile_events[ev.pubkey] = ev 324 model.profiles[ev.pubkey] = JSON.parse(ev.content) 325 } catch { 326 console.log("failed to parse", ev.content) 327 } 328 } 329 } 330 331 function handle_profile_event(model, ev) { 332 console.log("PROFILE", ev) 333 } 334 335 function send_initial_filters(account_id, pubkey, relay) { 336 const filter = {authors: [pubkey], kinds: [3], limit: 1} 337 //console.log("sending initial filter", filter) 338 relay.subscribe(account_id, filter) 339 } 340 341 function send_home_filters(ids, model, relay) { 342 const friends = contacts_friend_list(model.contacts) 343 friends.push(model.pubkey) 344 345 const contacts_filter = {kinds: [0], authors: friends} 346 const dms_filter = {kinds: [4], limit: 500} 347 const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} 348 const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} 349 const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} 350 351 let home_filters = [home_filter] 352 let notifications_filters = [notifications_filter] 353 let contacts_filters = [contacts_filter] 354 let dms_filters = [dms_filter, our_dms_filter] 355 356 let last_of_kind = {} 357 if (relay) { 358 last_of_kind = 359 model.last_event_of_kind[relay] = 360 model.last_event_of_kind[relay] || {} 361 } 362 363 update_filters_with_since(last_of_kind, home_filters) 364 update_filters_with_since(last_of_kind, contacts_filters) 365 update_filters_with_since(last_of_kind, notifications_filters) 366 update_filters_with_since(last_of_kind, dms_filters) 367 368 const subto = relay? [relay] : undefined 369 model.pool.subscribe(ids.home, home_filters, subto) 370 model.pool.subscribe(ids.contacts, contacts_filters, subto) 371 model.pool.subscribe(ids.notifications, notifications_filters, subto) 372 model.pool.subscribe(ids.dms, dms_filters, subto) 373 } 374 375 function get_since_time(last_event) { 376 if (!last_event) { 377 return null 378 } 379 380 return last_event.created_at - 60 * 10 381 } 382 383 function update_filter_with_since(last_of_kind, filter) { 384 const kinds = filter.kinds || [] 385 let initial = null 386 let earliest = kinds.reduce((earliest, kind) => { 387 const last = last_of_kind[kind] 388 let since = get_since_time(last) 389 390 if (!earliest) { 391 if (since === null) 392 return null 393 394 return since 395 } 396 397 if (since === null) 398 return earliest 399 400 return since < earliest ? since : earliest 401 402 }, initial) 403 404 if (earliest) 405 filter.since = earliest 406 } 407 408 function update_filters_with_since(last_of_kind, filters) { 409 for (const filter of filters) { 410 update_filter_with_since(last_of_kind, filter) 411 } 412 } 413 414 function contacts_friend_list(contacts) { 415 return Array.from(contacts.friends) 416 } 417 418 function process_contact_event(model, ev) { 419 load_our_contacts(model.contacts, model.pubkey, ev) 420 load_our_relays(model.pubkey, model.pool, ev) 421 add_contact_if_friend(model.contacts, ev) 422 } 423 424 function add_contact_if_friend(contacts, ev) { 425 if (!contact_is_friend(contacts, ev.pubkey)) { 426 return 427 } 428 429 add_friend_contact(contacts, ev) 430 } 431 432 function contact_is_friend(contacts, pk) { 433 return contacts.friends.has(pk) 434 } 435 436 function add_friend_contact(contacts, contact) { 437 contacts.friends[contact.pubkey] = true 438 439 for (const tag of contact.tags) { 440 if (tag.count >= 2 && tag[0] == "p") { 441 contacts.friend_of_friends.add(tag[1]) 442 } 443 } 444 } 445 446 function load_our_relays(our_pubkey, pool, ev) { 447 if (ev.pubkey != our_pubkey) 448 return 449 450 let relays 451 try { 452 relays = JSON.parse(ev.content) 453 } catch (e) { 454 log_debug("error loading relays", e) 455 return 456 } 457 458 for (const relay of Object.keys(relays)) { 459 log_debug("adding relay", relay) 460 if (!pool.has(relay)) 461 pool.add(relay) 462 } 463 } 464 465 function log_debug(fmt, ...args) { 466 console.log("[debug] " + fmt, ...args) 467 } 468 469 function load_our_contacts(contacts, our_pubkey, ev) { 470 if (ev.pubkey !== our_pubkey) 471 return 472 473 contacts.event = ev 474 475 for (const tag of ev.tags) { 476 if (tag.length > 1 && tag[0] === "p") { 477 contacts.friends.add(tag[1]) 478 } 479 } 480 } 481 482 function handle_profiles_loaded(profiles_id, model, relay) { 483 // stop asking for profiles 484 model.pool.unsubscribe(profiles_id, relay) 485 model.realtime = true 486 487 redraw_events(model) 488 } 489 490 function debounce(f, interval) { 491 let timer = null; 492 let first = true; 493 494 return (...args) => { 495 clearTimeout(timer); 496 return new Promise((resolve) => { 497 timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); 498 first = false 499 }); 500 }; 501 } 502 503 function get_unknown_chatroom_ids(state) 504 { 505 let chatroom_ids = [] 506 for (const key of Object.keys(state.chatrooms)) { 507 const chatroom = state.chatrooms[key] 508 if (chatroom.name === undefined) 509 chatroom_ids.push(key) 510 } 511 return chatroom_ids 512 } 513 514 // load profiles after comment notes are loaded 515 function handle_comments_loaded(profiles_id, model, relay) 516 { 517 const pubkeys = model.events.reduce((s, ev) => { 518 s.add(ev.pubkey) 519 return s 520 }, new Set()) 521 const authors = Array.from(pubkeys) 522 523 // load profiles and noticed chatrooms 524 const chatroom_ids = get_unknown_chatroom_ids(model) 525 const profile_filter = {kinds: [0], authors: authors} 526 const chatroom_filter = {kinds: [40], ids: chatroom_ids} 527 const filters = [profile_filter, chatroom_filter] 528 529 //console.log("subscribe", profiles_id, filter, relay) 530 model.pool.subscribe(profiles_id, filters, relay) 531 } 532 533 function redraw_events(model) { 534 //log_debug("rendering home view") 535 model.rendered = {} 536 model.events_el.innerHTML = render_events(model) 537 setup_home_event_handlers(model.events_el) 538 } 539 540 function setup_home_event_handlers(events_el) 541 { 542 for (const el of events_el.querySelectorAll(".cw")) 543 el.addEventListener("toggle", toggle_content_warning.bind(null)) 544 } 545 546 function redraw_home_view(model) { 547 model.view_el.innerHTML = render_home_view(model) 548 model.events_el = document.querySelector("#events") 549 if (model.events.length > 0) 550 redraw_events(model) 551 else 552 model.events_el.innerText = "Loading..." 553 } 554 555 async function send_post() { 556 const input_el = document.querySelector("#post-input") 557 const cw_el = document.querySelector("#content-warning-input") 558 559 const cw = cw_el.value 560 const content = input_el.value 561 const created_at = Math.floor(new Date().getTime() / 1000) 562 const kind = 1 563 const tags = cw? [["content-warning", cw]] : [] 564 const pubkey = await get_pubkey() 565 const {pool} = DSTATE 566 567 let post = { pubkey, tags, content, created_at, kind } 568 569 post.id = await nostrjs.calculate_id(post) 570 post = await sign_event(post) 571 572 pool.send(["EVENT", post]) 573 574 input_el.value = "" 575 cw_el.value = "" 576 } 577 578 async function sign_event(ev) { 579 if (window.nostr && window.nostr.signEvent) { 580 const signed = await window.nostr.signEvent(ev) 581 if (typeof signed === 'string') { 582 ev.sig = signed 583 return ev 584 } 585 return signed 586 } 587 588 const privkey = get_privkey() 589 ev.sig = await sign_id(privkey, ev.id) 590 return ev 591 } 592 593 function render_home_view(model) { 594 return ` 595 <div id="newpost"> 596 <div id="content-warning-input-container"> 597 <input id="content-warning-input" type="text" placeholder="Content Warning (nsfw, politics, etc)"></input> 598 </div> 599 600 <div class="post-group"> 601 <textarea placeholder="What's on your mind?" id="post-input"></textarea> 602 <button onclick="send_post(this)" id="post-button">Post</button> 603 </div> 604 </div> 605 <div id="events"> 606 </div> 607 ` 608 } 609 610 function render_home_event(model, ev) 611 { 612 let max_depth = 3 613 if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) { 614 max_depth = null 615 } 616 617 return render_event(model, ev, {max_depth}) 618 } 619 620 function render_events(model) { 621 return model.events 622 .filter((ev, i) => i < 140) 623 .map((ev) => render_home_event(model, ev)).join("\n") 624 } 625 626 function determine_event_refs_positionally(pubkeys, ids) 627 { 628 if (ids.length === 1) 629 return {root: ids[0], reply: ids[0], pubkeys} 630 else if (ids.length >= 2) 631 return {root: ids[0], reply: ids[1], pubkeys} 632 633 return {pubkeys} 634 } 635 636 function determine_event_refs(tags) { 637 let positional_ids = [] 638 let pubkeys = [] 639 let root 640 let reply 641 let i = 0 642 643 for (const tag of tags) { 644 if (tag.length >= 4 && tag[0] == "e") { 645 if (tag[3] === "root") 646 root = tag[1] 647 else if (tag[3] === "reply") 648 reply = tag[1] 649 } else if (tag.length >= 2 && tag[0] == "e") { 650 positional_ids.push(tag[1]) 651 } else if (tag.length >= 2 && tag[0] == "p") { 652 pubkeys.push(tag[1]) 653 } 654 655 i++ 656 } 657 658 if (!root && !reply && positional_ids.length > 0) 659 return determine_event_refs_positionally(pubkeys, positional_ids) 660 661 return {root, reply, pubkeys} 662 } 663 664 function render_reply_line_top(invisible) { 665 const classes = invisible ? "invisible" : "" 666 return `<div class="line-top ${classes}"></div>` 667 } 668 669 function render_reply_line_bot() { 670 return `<div class="line-bot"></div>` 671 } 672 673 function can_reply(ev) { 674 return ev.kind === 1 || ev.kind === 42 675 } 676 677 const DEFAULT_PROFILE = { 678 name: "anon", 679 display_name: "Anonymous", 680 } 681 682 function render_thread_collapsed(model, reply_ev, opts) 683 { 684 if (opts.is_composing) 685 return "" 686 return `<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed clickable">...</div>` 687 } 688 689 function* yield_etags(tags) 690 { 691 for (const tag of tags) { 692 if (tag.length >= 2 && tag[0] === "e") 693 yield tag 694 } 695 } 696 697 function expand_thread(id) { 698 const ev = DSTATE.all_events[id] 699 if (ev) { 700 for (const tag of yield_etags(ev.tags)) 701 DSTATE.expanded.add(tag[1]) 702 } 703 DSTATE.expanded.add(id) 704 redraw_events(DSTATE) 705 } 706 707 function render_replied_events(model, ev, opts) 708 { 709 if (!(ev.refs && ev.refs.reply)) 710 return "" 711 712 const reply_ev = model.all_events[ev.refs.reply] 713 if (!reply_ev) 714 return "" 715 716 opts.replies = opts.replies == null ? 1 : opts.replies + 1 717 if (!(opts.max_depth == null || opts.replies < opts.max_depth)) 718 return render_thread_collapsed(model, reply_ev, opts) 719 720 opts.is_reply = true 721 return render_event(model, reply_ev, opts) 722 } 723 724 function render_replying_to_chat(model, ev) { 725 const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {} 726 const roomname = chatroom.name || ev.refs.root || "??" 727 const pks = ev.refs.pubkeys || [] 728 const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") 729 const to_users = pks.length === 0 ? "" : ` to ${names}` 730 731 return `<div class="replying-to small-txt">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` 732 } 733 734 function render_replying_to(model, ev) { 735 if (!(ev.refs && ev.refs.reply)) 736 return "" 737 738 if (ev.kind === 42) 739 return render_replying_to_chat(model, ev) 740 741 let pubkeys = ev.refs.pubkeys || [] 742 if (pubkeys.length === 0 && ev.refs.reply) { 743 const replying_to = model.all_events[ev.refs.reply] 744 if (!replying_to) 745 return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` 746 747 pubkeys = [replying_to.pubkey] 748 } 749 750 const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") 751 752 return ` 753 <span class="replying-to small-txt"> 754 replying to ${names} 755 </span> 756 ` 757 } 758 759 function render_delete_post(model, ev) { 760 if (model.pubkey !== ev.pubkey) 761 return "" 762 763 return ` 764 <span onclick="delete_post_confirm('${ev.id}')" class="clickable" style="float: right"> 765 ✕ 766 </span> 767 ` 768 } 769 770 function delete_post_confirm(evid) { 771 if (!confirm("Are you sure you want to delete this post?")) 772 return 773 774 const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() 775 776 if (reason.toLowerCase() === "cancel") 777 return 778 779 delete_post(evid, reason) 780 } 781 782 function render_unknown_event(model, ev) { 783 return "Unknown event" 784 } 785 786 function render_boost(model, ev, opts) { 787 //todo validate content 788 if (!ev.json_content) 789 return render_unknown_event(ev) 790 791 const profile = model.profiles[ev.pubkey] 792 opts.is_boost_event = true 793 return ` 794 <div class="boost"> 795 <div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div> 796 ${render_event(model, ev.json_content, opts)} 797 </div> 798 ` 799 } 800 801 function shouldnt_render_event(model, ev, opts) { 802 return !opts.is_boost_event && !opts.is_composing && !model.expanded.has(ev.id) && model.rendered[ev.id] 803 } 804 805 function render_deleted_name() { 806 return "???" 807 } 808 809 function render_deleted_pfp() { 810 return `<div class="pfp pfp-normal">😵</div>` 811 } 812 813 function render_comment_body(model, ev, opts) { 814 const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev) 815 const show_media = !opts.is_composing 816 817 return ` 818 <div> 819 ${render_replying_to(model, ev)} 820 ${render_delete_post(model, ev)} 821 </div> 822 <p> 823 ${format_content(ev, show_media)} 824 </p> 825 ${render_reactions(model, ev)} 826 ${bar} 827 ` 828 } 829 830 function render_deleted_comment_body(ev, deleted) { 831 if (deleted.content) { 832 const show_media = false 833 return ` 834 <div class="deleted-comment"> 835 This comment was deleted. Reason: 836 <div class="quote">${format_content(deleted, show_media)}</div> 837 </div> 838 ` 839 } 840 841 842 return `<div class="deleted-comment">This comment was deleted</div>` 843 } 844 845 function render_event(model, ev, opts={}) { 846 if (ev.kind === 6) 847 return render_boost(model, ev, opts) 848 if (shouldnt_render_event(model, ev, opts)) 849 return "" 850 delete opts.is_boost_event 851 model.rendered[ev.id] = true 852 const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE 853 const delta = time_delta(new Date().getTime(), ev.created_at*1000) 854 855 const has_bot_line = opts.is_reply 856 const reply_line_bot = (has_bot_line && render_reply_line_bot()) || "" 857 858 const deleted = is_deleted(model, ev.id) 859 if (deleted && !opts.is_reply) 860 return "" 861 862 const replied_events = render_replied_events(model, ev, opts) 863 864 return ` 865 ${replied_events} 866 <div id="ev${ev.id}" class="comment"> 867 <div class="info"> 868 ${deleted ? render_deleted_name() : render_name(ev.pubkey, profile)} 869 <span class="timestamp">${delta}</span> 870 </div> 871 <div class="pfpbox"> 872 ${render_reply_line_top(replied_events === "")} 873 ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} 874 ${reply_line_bot} 875 </div> 876 <div class="comment-body"> 877 ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)} 878 </div> 879 </div> 880 ` 881 } 882 883 function render_pfp(pk, profile, size="normal") { 884 const name = render_name_plain(pk, profile) 885 return `<img title="${name}" class="pfp pfp-${size}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">` 886 } 887 888 const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)$/ 889 890 const WORD_REGEX=/\w/ 891 function is_emoji(str) 892 { 893 return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) 894 } 895 896 function is_valid_reaction_content(content) 897 { 898 return content === "+" || content === "" || is_emoji(content) 899 } 900 901 function get_reaction_emoji(ev) { 902 if (ev.content === "+" || ev.content === "") 903 return "❤️" 904 905 return ev.content 906 } 907 908 function render_reaction_group(model, emoji, reactions, reacting_to) { 909 const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk])) 910 911 let onclick = "" 912 const reaction = reactions[model.pubkey] 913 if (!reaction) { 914 onclick = `onclick="send_reply('${emoji}', '${reacting_to.id}')"` 915 } else { 916 onclick = `onclick="delete_post('${reaction.id}')"` 917 } 918 919 return ` 920 <span ${onclick} class="reaction-group clickable"> 921 <span class="reaction-emoji"> 922 ${emoji} 923 </span> 924 ${pfps.join("\n")} 925 </span> 926 ` 927 } 928 929 async function delete_post(id, reason) 930 { 931 const ev = DSTATE.all_events[id] 932 if (!ev) 933 return 934 935 const pubkey = await get_pubkey() 936 let del = await create_deletion_event(pubkey, id, reason) 937 console.log("deleting", ev) 938 broadcast_event(del) 939 } 940 941 function render_reaction(model, reaction) { 942 const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE 943 let emoji = reaction.content[0] 944 if (reaction.content === "+" || reaction.content === "") 945 emoji = "❤️" 946 947 return render_pfp(reaction.pubkey, profile, "small") 948 } 949 950 function render_reactions(model, ev) { 951 const reactions_set = model.reactions_to[ev.id] 952 if (!reactions_set) 953 return "" 954 955 let reactions = [] 956 for (const id of reactions_set.keys()) { 957 if (is_deleted(model, id)) 958 continue 959 const reaction = model.all_events[id] 960 if (!reaction) 961 continue 962 reactions.push(reaction) 963 } 964 965 let str = "" 966 const groups = reactions.reduce((grp, r) => { 967 const e = get_reaction_emoji(r) 968 grp[e] = grp[e] || {} 969 grp[e][r.pubkey] = r 970 return grp 971 }, {}) 972 973 for (const emoji of Object.keys(groups)) { 974 str += render_reaction_group(model, emoji, groups[emoji], ev) 975 } 976 977 return ` 978 <div class="reactions"> 979 ${str} 980 </div> 981 ` 982 } 983 984 function close_reply() { 985 const modal = document.querySelector("#reply-modal") 986 modal.style.display = "none"; 987 } 988 989 function gather_reply_tags(pubkey, from) { 990 let tags = [] 991 for (const tag of from.tags) { 992 if (tag.length >= 2) { 993 if (tag[0] === "e") { 994 tags.push(tag) 995 } else if (tag[0] === "p" && tag[1] !== pubkey) { 996 tags.push(tag) 997 } 998 } 999 } 1000 tags.push(["e", from.id, "", "reply"]) 1001 if (from.pubkey !== pubkey) 1002 tags.push(["p", from.pubkey]) 1003 return tags 1004 } 1005 1006 async function create_deletion_event(pubkey, target, content="") 1007 { 1008 const created_at = Math.floor(new Date().getTime() / 1000) 1009 let kind = 5 1010 1011 const tags = [["e", target]] 1012 let del = { pubkey, tags, content, created_at, kind } 1013 1014 del.id = await nostrjs.calculate_id(del) 1015 del = await sign_event(del) 1016 return del 1017 } 1018 1019 async function create_reply(pubkey, content, from) { 1020 const tags = gather_reply_tags(pubkey, from) 1021 const created_at = Math.floor(new Date().getTime() / 1000) 1022 let kind = from.kind 1023 1024 // convert emoji replies into reactions 1025 if (is_valid_reaction_content(content)) 1026 kind = 7 1027 1028 let reply = { pubkey, tags, content, created_at, kind } 1029 1030 reply.id = await nostrjs.calculate_id(reply) 1031 reply = await sign_event(reply) 1032 return reply 1033 } 1034 1035 function get_tag_event(tag) 1036 { 1037 if (tag.length < 2) 1038 return null 1039 1040 if (tag[0] === "e") 1041 return DSTATE.all_events[tag[1]] 1042 1043 if (tag[0] === "p") 1044 return DSTATE.profile_events[tag[1]] 1045 1046 return null 1047 } 1048 1049 async function broadcast_related_events(ev) 1050 { 1051 ev.tags 1052 .reduce((evs, tag) => { 1053 // cap it at something sane 1054 if (evs.length >= 5) 1055 return evs 1056 const ev = get_tag_event(tag) 1057 if (!ev) 1058 return evs 1059 insert_event_sorted(evs, ev) // for uniqueness 1060 return evs 1061 }, []) 1062 .forEach((ev, i) => { 1063 // so we don't get rate limited 1064 setTimeout(() => { 1065 log_debug("broadcasting related event", ev) 1066 broadcast_event(ev) 1067 }, (i+1)*1200) 1068 }) 1069 } 1070 1071 function broadcast_event(ev) { 1072 DSTATE.pool.send(["EVENT", ev]) 1073 } 1074 1075 async function send_reply(content, replying_to) 1076 { 1077 const ev = DSTATE.all_events[replying_to] 1078 if (!ev) 1079 return 1080 1081 const pubkey = await get_pubkey() 1082 let reply = await create_reply(pubkey, content, ev) 1083 1084 broadcast_event(reply) 1085 broadcast_related_events(reply) 1086 } 1087 1088 async function do_send_reply() { 1089 const modal = document.querySelector("#reply-modal") 1090 const replying_to = modal.querySelector("#replying-to") 1091 1092 const evid = replying_to.dataset.evid 1093 const reply_content_el = document.querySelector("#reply-content") 1094 const content = reply_content_el.value 1095 1096 await send_reply(content, evid) 1097 1098 reply_content_el.value = "" 1099 1100 close_reply() 1101 } 1102 1103 function bech32_decode(pubkey) { 1104 const decoded = bech32.decode(pubkey) 1105 const bytes = fromWords(decoded.words) 1106 return nostrjs.hex_encode(bytes) 1107 } 1108 1109 function get_local_state(key) { 1110 if (DSTATE[key] != null) 1111 return DSTATE[key] 1112 1113 return localStorage.getItem(key) 1114 } 1115 1116 function set_local_state(key, val) { 1117 DSTATE[key] = val 1118 localStorage.setItem(key, val) 1119 } 1120 1121 async function get_pubkey() { 1122 let pubkey = get_local_state('pubkey') 1123 1124 if (pubkey) 1125 return pubkey 1126 1127 if (window.nostr && window.nostr.getPublicKey) { 1128 const pubkey = await window.nostr.getPublicKey() 1129 console.log("got %s pubkey from nos2x", pubkey) 1130 return pubkey 1131 } 1132 1133 pubkey = prompt("Enter pubkey (hex or npub)") 1134 1135 if (!pubkey) 1136 throw new Error("Need pubkey to continue") 1137 1138 if (pubkey[0] === "n") 1139 pubkey = bech32_decode(pubkey) 1140 1141 set_local_state('pubkey', pubkey) 1142 return pubkey 1143 } 1144 1145 function get_privkey() { 1146 let privkey = get_local_state('privkey') 1147 1148 if (privkey) 1149 return privkey 1150 1151 if (!privkey) 1152 privkey = prompt("Enter private key") 1153 1154 if (!privkey) 1155 throw new Error("can't get privkey") 1156 1157 if (privkey[0] === "n") { 1158 privkey = bech32_decode(privkey) 1159 } 1160 1161 set_local_state('privkey', privkey) 1162 1163 return privkey 1164 } 1165 1166 async function sign_id(privkey, id) 1167 { 1168 //const digest = nostrjs.hex_decode(id) 1169 const sig = await nobleSecp256k1.schnorr.sign(id, privkey) 1170 return nostrjs.hex_encode(sig) 1171 } 1172 1173 function reply_to(evid) { 1174 const modal = document.querySelector("#reply-modal") 1175 const replying = modal.style.display === "none"; 1176 const replying_to = modal.querySelector("#replying-to") 1177 1178 replying_to.dataset.evid = evid 1179 const ev = DSTATE.all_events[evid] 1180 replying_to.innerHTML = render_event(DSTATE, ev, {is_composing: true, nobar: true, max_depth: 1}) 1181 1182 modal.style.display = replying? "block" : "none"; 1183 } 1184 1185 function render_action_bar(ev) { 1186 return ` 1187 <div class="action-bar"> 1188 <a href="javascript:reply_to('${ev.id}')">reply</a> 1189 </div> 1190 ` 1191 } 1192 1193 const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i 1194 function is_img_url(path) { 1195 return IMG_REGEX.test(path) 1196 } 1197 1198 const VID_REGEX = /(webm|mp4)$/i 1199 function is_video_url(path) { 1200 return VID_REGEX.test(path) 1201 } 1202 1203 const URL_REGEX = /(https?:\/\/[^\s\):]+)/g; 1204 function linkify(text, show_media) { 1205 return text.replace(URL_REGEX, function(url) { 1206 const parsed = new URL(url) 1207 if (show_media && is_img_url(parsed.pathname)) 1208 return `<img class="inline-img" src="${url}"/>`; 1209 else if (show_media && is_video_url(parsed.pathname)) 1210 return ` 1211 <video controls class="inline-img" /> 1212 <source src="${url}"> 1213 </video> 1214 `; 1215 else 1216 return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; 1217 }) 1218 } 1219 1220 function convert_quote_blocks(content, show_media) 1221 { 1222 const split = content.split("\n") 1223 let blockin = false 1224 return split.reduce((str, line) => { 1225 if (line !== "" && line[0] === '>') { 1226 if (!blockin) { 1227 str += "<span class='quote'>" 1228 blockin = true 1229 } 1230 str += linkify(sanitize(line.slice(1)), show_media) 1231 } else { 1232 if (blockin) { 1233 blockin = false 1234 str += "</span>" 1235 } 1236 str += linkify(sanitize(line), show_media) 1237 } 1238 return str + "<br/>" 1239 }, "") 1240 } 1241 1242 function get_content_warning(tags) 1243 { 1244 for (const tag of tags) { 1245 if (tag.length >= 1 && tag[0] === "content-warning") 1246 return tag[1] || "" 1247 } 1248 1249 return null 1250 } 1251 1252 function toggle_content_warning(e) 1253 { 1254 const el = e.target 1255 const id = el.id.split("_")[1] 1256 const ev = DSTATE.all_events[id] 1257 1258 if (!ev) { 1259 log_debug("could not find content-warning event", id) 1260 return 1261 } 1262 1263 DSTATE.cw_open[id] = el.open 1264 } 1265 1266 function format_content(ev, show_media) 1267 { 1268 if (ev.kind === 7) { 1269 if (ev.content === "" || ev.content === "+") 1270 return "❤️" 1271 return sanitize(ev.content.trim()) 1272 } 1273 1274 const content = ev.content.trim() 1275 const body = convert_quote_blocks(content, show_media) 1276 1277 let cw = get_content_warning(ev.tags) 1278 if (cw !== null) { 1279 cw = cw === ""? "Content Warning" : `Content Warning: ${cw}` 1280 const open = !!DSTATE.cw_open[ev.id]? "open" : "" 1281 return ` 1282 <details class="cw" id="cw_${ev.id}" ${open}> 1283 <summary>${cw}</summary> 1284 ${body} 1285 </details> 1286 ` 1287 } 1288 1289 return body 1290 } 1291 1292 function sanitize(content) 1293 { 1294 if (!content) 1295 return "" 1296 return content.replaceAll("<","<").replaceAll(">",">") 1297 } 1298 1299 function robohash(pk) { 1300 return "https://robohash.org/" + pk 1301 } 1302 1303 function get_picture(pk, profile) { 1304 if (profile.resolved_picture) 1305 return profile.resolved_picture 1306 profile.resolved_picture = sanitize(profile.picture) || robohash(pk) 1307 return profile.resolved_picture 1308 } 1309 1310 function render_name_plain(pk, profile=DEFAULT_PROFILE) 1311 { 1312 if (profile.sanitized_name) 1313 return profile.sanitized_name 1314 1315 const display_name = profile.display_name || profile.user 1316 const username = profile.name || "anon" 1317 const name = display_name || username 1318 1319 profile.sanitized_name = sanitize(name) 1320 return profile.sanitized_name 1321 } 1322 1323 function render_pubkey(pk) 1324 { 1325 return pk.slice(-8) 1326 } 1327 1328 function render_username(pk, profile) 1329 { 1330 return (profile && profile.name) || render_pubkey(pk) 1331 } 1332 1333 function render_mentioned_name(pk, profile) { 1334 return `<span class="username">@${render_username(pk, profile)}</span>` 1335 } 1336 1337 function render_name(pk, profile) { 1338 return `<div class="username">${render_name_plain(pk, profile)}</div>` 1339 } 1340 1341 function time_delta(current, previous) { 1342 var msPerMinute = 60 * 1000; 1343 var msPerHour = msPerMinute * 60; 1344 var msPerDay = msPerHour * 24; 1345 var msPerMonth = msPerDay * 30; 1346 var msPerYear = msPerDay * 365; 1347 1348 var elapsed = current - previous; 1349 1350 if (elapsed < msPerMinute) { 1351 return Math.round(elapsed/1000) + ' seconds ago'; 1352 } 1353 1354 else if (elapsed < msPerHour) { 1355 return Math.round(elapsed/msPerMinute) + ' minutes ago'; 1356 } 1357 1358 else if (elapsed < msPerDay ) { 1359 return Math.round(elapsed/msPerHour ) + ' hours ago'; 1360 } 1361 1362 else if (elapsed < msPerMonth) { 1363 return Math.round(elapsed/msPerDay) + ' days ago'; 1364 } 1365 1366 else if (elapsed < msPerYear) { 1367 return Math.round(elapsed/msPerMonth) + ' months ago'; 1368 } 1369 1370 else { 1371 return Math.round(elapsed/msPerYear ) + ' years ago'; 1372 } 1373 }