damus.js (47263B)
1 2 let DAMUS 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 DAMUS = 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 refevents: "refevents",//uuidv4(), 110 account: "account",//uuidv4(), 111 home: "home",//uuidv4(), 112 contacts: "contacts",//uuidv4(), 113 notifications: "notifications",//uuidv4(), 114 dms: "dms",//uuidv4(), 115 } 116 117 model.pool = pool 118 model.view_el = document.querySelector("#view") 119 redraw_home_view(model) 120 121 document.addEventListener('visibilitychange', () => { 122 update_title(model) 123 }) 124 125 pool.on('open', (relay) => { 126 //let authors = followers 127 // TODO: fetch contact list 128 log_debug("relay connected", relay.url) 129 130 if (!model.done_init) { 131 model.loading = false 132 133 send_initial_filters(ids.account, model.pubkey, relay) 134 } else { 135 send_home_filters(ids, model, relay) 136 } 137 //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) 138 }); 139 140 pool.on('event', (relay, sub_id, ev) => { 141 handle_home_event(ids, model, relay, sub_id, ev) 142 }) 143 144 pool.on('eose', async (relay, sub_id) => { 145 if (sub_id === ids.home) { 146 handle_comments_loaded(ids.profiles, model, relay) 147 } else if (sub_id === ids.profiles) { 148 handle_profiles_loaded(ids.profiles, model, relay) 149 } 150 }) 151 152 return pool 153 } 154 155 function process_reaction_event(model, ev) 156 { 157 if (!is_valid_reaction_content(ev.content)) 158 return 159 160 let last = {} 161 162 for (const tag of ev.tags) { 163 if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) 164 last[tag[0]] = tag[1] 165 } 166 167 if (last.e) { 168 model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() 169 model.reactions_to[last.e].add(ev.id) 170 } 171 } 172 173 function process_chatroom_event(model, ev) 174 { 175 try { 176 model.chatrooms[ev.id] = JSON.parse(ev.content) 177 } catch (err) { 178 log_debug("error processing chatroom creation event", ev, err) 179 } 180 } 181 182 function process_json_content(ev) 183 { 184 try { 185 ev.json_content = JSON.parse(ev.content) 186 } catch(e) { 187 log_debug("error parsing json content for", ev) 188 } 189 } 190 191 function process_deletion_event(model, ev) 192 { 193 for (const tag of ev.tags) { 194 if (tag.length >= 2 && tag[0] === "e") { 195 const evid = tag[1] 196 197 // we've already recorded this one as a valid deleted event 198 // we can just ignore it 199 if (model.deleted[evid]) 200 continue 201 202 let ds = model.deletions[evid] = (model.deletions[evid] || new Set()) 203 204 // add the deletion event id to the deletion set of this event 205 // we will use this to determine if this event is valid later in 206 // case we don't have the deleted event yet. 207 ds.add(ev.id) 208 } 209 } 210 } 211 212 function is_deleted(model, evid) 213 { 214 // we've already know it's deleted 215 if (model.deleted[evid]) 216 return model.deleted[evid] 217 218 const ev = model.all_events[evid] 219 if (!ev) 220 return false 221 222 // all deletion events 223 const ds = model.deletions[ev.id] 224 if (!ds) 225 return false 226 227 // find valid deletion events 228 for (const id of ds.keys()) { 229 const d_ev = model.all_events[id] 230 if (!d_ev) 231 continue 232 233 // only allow deletes from the user who created it 234 if (d_ev.pubkey === ev.pubkey) { 235 model.deleted[ev.id] = d_ev 236 log_debug("received deletion for", ev) 237 // clean up deletion data that we don't need anymore 238 delete model.deletions[ev.id] 239 return true 240 } else { 241 log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) 242 } 243 } 244 245 return false 246 } 247 248 function process_event(model, ev) 249 { 250 ev.refs = determine_event_refs(ev.tags) 251 const notified = was_pubkey_notified(model.pubkey, ev) 252 ev.notified = notified 253 254 if (ev.kind === 7) 255 process_reaction_event(model, ev) 256 else if (ev.kind === 42 && ev.refs && ev.refs.root) 257 notice_chatroom(model, ev.refs.root) 258 else if (ev.kind === 40) 259 process_chatroom_event(model, ev) 260 else if (ev.kind === 6) 261 process_json_content(ev) 262 else if (ev.kind === 5) 263 process_deletion_event(model, ev) 264 else if (ev.kind === 0) 265 process_profile_event(model, ev) 266 267 const last_notified = get_local_state('last_notified_date') 268 if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { 269 set_local_state('last_notified_date', new Date().getTime()) 270 model.notifications++ 271 update_title(model) 272 } 273 } 274 275 function was_pubkey_notified(pubkey, ev) 276 { 277 if (!(ev.kind === 1 || ev.kind === 42)) 278 return false 279 280 if (ev.pubkey === pubkey) 281 return false 282 283 for (const tag of ev.tags) { 284 if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) 285 return true 286 } 287 288 return false 289 } 290 291 function should_add_to_home(ev) 292 { 293 return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 294 } 295 296 let rerender_home_timer 297 function handle_home_event(ids, model, relay, sub_id, ev) { 298 // ignore duplicates 299 if (model.all_events[ev.id]) 300 return 301 302 model.all_events[ev.id] = ev 303 process_event(model, ev) 304 305 switch (sub_id) { 306 case ids.home: 307 if (should_add_to_home(ev)) 308 insert_event_sorted(model.events, ev) 309 310 if (model.realtime) { 311 if (rerender_home_timer) 312 clearTimeout(rerender_home_timer) 313 rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) 314 } 315 break; 316 case ids.account: 317 switch (ev.kind) { 318 case 3: 319 model.loading = false 320 process_contact_event(model, ev) 321 model.done_init = true 322 model.pool.unsubscribe(ids.account, [relay]) 323 break 324 } 325 case ids.profiles: 326 break 327 } 328 } 329 330 function process_profile_event(model, ev) { 331 const prev_ev = model.profile_events[ev.pubkey] 332 if (prev_ev && prev_ev.created_at > ev.created_at) 333 return 334 335 model.profile_events[ev.pubkey] = ev 336 try { 337 model.profiles[ev.pubkey] = JSON.parse(ev.content) 338 } catch(e) { 339 log_debug("failed to parse profile contents", ev) 340 } 341 } 342 343 function send_initial_filters(account_id, pubkey, relay) { 344 const filter = {authors: [pubkey], kinds: [3], limit: 1} 345 //console.log("sending initial filter", filter) 346 relay.subscribe(account_id, filter) 347 } 348 349 function send_home_filters(ids, model, relay) { 350 const friends = contacts_friend_list(model.contacts) 351 friends.push(model.pubkey) 352 353 const contacts_filter = {kinds: [0], authors: friends} 354 const dms_filter = {kinds: [4], limit: 500} 355 const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} 356 const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} 357 const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} 358 359 let home_filters = [home_filter] 360 let notifications_filters = [notifications_filter] 361 let contacts_filters = [contacts_filter] 362 let dms_filters = [dms_filter, our_dms_filter] 363 364 let last_of_kind = {} 365 if (relay) { 366 last_of_kind = 367 model.last_event_of_kind[relay] = 368 model.last_event_of_kind[relay] || {} 369 } 370 371 update_filters_with_since(last_of_kind, home_filters) 372 update_filters_with_since(last_of_kind, contacts_filters) 373 update_filters_with_since(last_of_kind, notifications_filters) 374 update_filters_with_since(last_of_kind, dms_filters) 375 376 const subto = relay? [relay] : undefined 377 model.pool.subscribe(ids.home, home_filters, subto) 378 model.pool.subscribe(ids.contacts, contacts_filters, subto) 379 model.pool.subscribe(ids.notifications, notifications_filters, subto) 380 model.pool.subscribe(ids.dms, dms_filters, subto) 381 } 382 383 function get_since_time(last_event) { 384 if (!last_event) { 385 return null 386 } 387 388 return last_event.created_at - 60 * 10 389 } 390 391 function update_filter_with_since(last_of_kind, filter) { 392 const kinds = filter.kinds || [] 393 let initial = null 394 let earliest = kinds.reduce((earliest, kind) => { 395 const last = last_of_kind[kind] 396 let since = get_since_time(last) 397 398 if (!earliest) { 399 if (since === null) 400 return null 401 402 return since 403 } 404 405 if (since === null) 406 return earliest 407 408 return since < earliest ? since : earliest 409 410 }, initial) 411 412 if (earliest) 413 filter.since = earliest 414 } 415 416 function update_filters_with_since(last_of_kind, filters) { 417 for (const filter of filters) { 418 update_filter_with_since(last_of_kind, filter) 419 } 420 } 421 422 function contacts_friend_list(contacts) { 423 return Array.from(contacts.friends) 424 } 425 426 function process_contact_event(model, ev) { 427 load_our_contacts(model.contacts, model.pubkey, ev) 428 load_our_relays(model.pubkey, model.pool, ev) 429 add_contact_if_friend(model.contacts, ev) 430 } 431 432 function add_contact_if_friend(contacts, ev) { 433 if (!contact_is_friend(contacts, ev.pubkey)) { 434 return 435 } 436 437 add_friend_contact(contacts, ev) 438 } 439 440 function contact_is_friend(contacts, pk) { 441 return contacts.friends.has(pk) 442 } 443 444 function add_friend_contact(contacts, contact) { 445 contacts.friends[contact.pubkey] = true 446 447 for (const tag of contact.tags) { 448 if (tag.count >= 2 && tag[0] == "p") { 449 contacts.friend_of_friends.add(tag[1]) 450 } 451 } 452 } 453 454 function load_our_relays(our_pubkey, pool, ev) { 455 if (ev.pubkey != our_pubkey) 456 return 457 458 let relays 459 try { 460 relays = JSON.parse(ev.content) 461 } catch (e) { 462 log_debug("error loading relays", e) 463 return 464 } 465 466 for (const relay of Object.keys(relays)) { 467 log_debug("adding relay", relay) 468 if (!pool.has(relay)) 469 pool.add(relay) 470 } 471 } 472 473 function log_debug(fmt, ...args) { 474 console.log("[debug] " + fmt, ...args) 475 } 476 477 function load_our_contacts(contacts, our_pubkey, ev) { 478 if (ev.pubkey !== our_pubkey) 479 return 480 481 contacts.event = ev 482 483 for (const tag of ev.tags) { 484 if (tag.length > 1 && tag[0] === "p") { 485 contacts.friends.add(tag[1]) 486 } 487 } 488 } 489 490 function get_referenced_events(model) 491 { 492 let evset = new Set() 493 for (const ev of model.events) { 494 for (const tag of ev.tags) { 495 if (tag.count >= 2 && tag[0] === "e") { 496 const e = tag[1] 497 if (!model.all_events[e]) { 498 evset.add(e) 499 } 500 } 501 } 502 } 503 return Array.from(evset) 504 } 505 506 507 function fetch_referenced_events(refevents_id, model, relay) { 508 509 const ref = df 510 model.pool.subscribe(refevents_id, [filter], relay) 511 } 512 513 function handle_profiles_loaded(profiles_id, model, relay) { 514 // stop asking for profiles 515 model.pool.unsubscribe(profiles_id, relay) 516 model.realtime = true 517 518 redraw_events(model) 519 } 520 521 function debounce(f, interval) { 522 let timer = null; 523 let first = true; 524 525 return (...args) => { 526 clearTimeout(timer); 527 return new Promise((resolve) => { 528 timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); 529 first = false 530 }); 531 }; 532 } 533 534 function get_unknown_chatroom_ids(state) 535 { 536 let chatroom_ids = [] 537 for (const key of Object.keys(state.chatrooms)) { 538 const chatroom = state.chatrooms[key] 539 if (chatroom.name === undefined) 540 chatroom_ids.push(key) 541 } 542 return chatroom_ids 543 } 544 545 // load profiles after comment notes are loaded 546 function handle_comments_loaded(profiles_id, model, relay) 547 { 548 const pubkeys = model.events.reduce((s, ev) => { 549 s.add(ev.pubkey) 550 return s 551 }, new Set()) 552 const authors = Array.from(pubkeys) 553 554 // load profiles and noticed chatrooms 555 const chatroom_ids = get_unknown_chatroom_ids(model) 556 const profile_filter = {kinds: [0], authors: authors} 557 const chatroom_filter = {kinds: [40], ids: chatroom_ids} 558 559 let filters = [profile_filter, chatroom_filter] 560 561 const ref_evids = get_referenced_events(model) 562 if (ref_evids.length > 0) { 563 log_debug("got %d new referenced events to pull after initial load", ref_evids.length) 564 filters.push({ids: ref_evids}) 565 filters.push({"#e": ref_evids}) 566 } 567 568 //console.log("subscribe", profiles_id, filter, relay) 569 model.pool.subscribe(profiles_id, filters, relay) 570 } 571 572 function redraw_events(model) { 573 //log_debug("rendering home view") 574 model.rendered = {} 575 model.events_el.innerHTML = render_events(model) 576 setup_home_event_handlers(model.events_el) 577 } 578 579 function setup_home_event_handlers(events_el) 580 { 581 for (const el of events_el.querySelectorAll(".cw")) 582 el.addEventListener("toggle", toggle_content_warning.bind(null)) 583 } 584 585 function redraw_home_view(model) { 586 model.view_el.innerHTML = render_home_view(model) 587 model.events_el = document.querySelector("#events") 588 if (model.events.length > 0) { 589 redraw_events(model) 590 } else { 591 model.events_el.innerHTML= ` 592 <div class="loading-events"> 593 <span class="loader" title="Loading..."> 594 <i class="fa-solid fa-fw fa-spin fa-hurricane" 595 style="--fa-animation-duration: 0.5s;"></i> 596 </span> 597 </div> 598 ` 599 } 600 } 601 602 async function send_post() { 603 const input_el = document.querySelector("#post-input") 604 const cw_el = document.querySelector("#content-warning-input") 605 606 const cw = cw_el.value 607 const content = input_el.value 608 const created_at = Math.floor(new Date().getTime() / 1000) 609 const kind = 1 610 const tags = cw ? [["content-warning", cw]] : [] 611 const pubkey = await get_pubkey() 612 const {pool} = DAMUS 613 614 let post = { pubkey, tags, content, created_at, kind } 615 616 post.id = await nostrjs.calculate_id(post) 617 post = await sign_event(post) 618 619 pool.send(["EVENT", post]) 620 621 input_el.value = "" 622 cw_el.value = "" 623 post_input_changed(input_el) 624 } 625 626 async function sign_event(ev) { 627 if (window.nostr && window.nostr.signEvent) { 628 const signed = await window.nostr.signEvent(ev) 629 if (typeof signed === 'string') { 630 ev.sig = signed 631 return ev 632 } 633 return signed 634 } 635 636 const privkey = get_privkey() 637 ev.sig = await sign_id(privkey, ev.id) 638 return ev 639 } 640 641 function render_home_view(model) { 642 return ` 643 <div id="newpost"> 644 <div><!-- empty to accomodate profile pic --></div> 645 <div> 646 <textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea> 647 <div class="post-tools"> 648 <input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/> 649 <button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon"> 650 <i class="fa-solid fa-triangle-exclamation"></i> 651 </button> 652 <button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button> 653 </div> 654 </div> 655 </div> 656 <div id="events"></div> 657 ` 658 } 659 660 function post_input_changed(el) 661 { 662 document.querySelector("#post-button").disabled = el.value === "" 663 } 664 665 function render_home_event(model, ev) 666 { 667 let max_depth = 3 668 if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) { 669 max_depth = null 670 } 671 672 return render_event(model, ev, {max_depth}) 673 } 674 675 function render_events(model) { 676 return model.events 677 .filter((ev, i) => i < 140) 678 .map((ev) => render_home_event(model, ev)).join("\n") 679 } 680 681 function determine_event_refs_positionally(pubkeys, ids) 682 { 683 if (ids.length === 1) 684 return {root: ids[0], reply: ids[0], pubkeys} 685 else if (ids.length >= 2) 686 return {root: ids[0], reply: ids[1], pubkeys} 687 688 return {pubkeys} 689 } 690 691 function determine_event_refs(tags) { 692 let positional_ids = [] 693 let pubkeys = [] 694 let root 695 let reply 696 let i = 0 697 698 for (const tag of tags) { 699 if (tag.length >= 4 && tag[0] == "e") { 700 if (tag[3] === "root") 701 root = tag[1] 702 else if (tag[3] === "reply") 703 reply = tag[1] 704 } else if (tag.length >= 2 && tag[0] == "e") { 705 positional_ids.push(tag[1]) 706 } else if (tag.length >= 2 && tag[0] == "p") { 707 pubkeys.push(tag[1]) 708 } 709 710 i++ 711 } 712 713 if (!root && !reply && positional_ids.length > 0) 714 return determine_event_refs_positionally(pubkeys, positional_ids) 715 716 return {root, reply, pubkeys} 717 } 718 719 function render_reply_line_top(has_top_line) { 720 const classes = has_top_line ? "" : "invisible" 721 return `<div class="line-top ${classes}"></div>` 722 } 723 724 function render_reply_line_bot() { 725 return `<div class="line-bot"></div>` 726 } 727 728 function can_reply(ev) { 729 return ev.kind === 1 || ev.kind === 42 730 } 731 732 const DEFAULT_PROFILE = { 733 name: "anon", 734 display_name: "Anonymous", 735 } 736 737 function render_thread_collapsed(model, reply_ev, opts) 738 { 739 if (opts.is_composing) 740 return "" 741 return ` 742 <div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed"> 743 <div class="thread-summary"> 744 More messages in thread available. Click to expand. 745 </div> 746 </div>` 747 } 748 749 function* yield_etags(tags) 750 { 751 for (const tag of tags) { 752 if (tag.length >= 2 && tag[0] === "e") 753 yield tag 754 } 755 } 756 757 function expand_thread(id) { 758 const ev = DAMUS.all_events[id] 759 if (ev) { 760 for (const tag of yield_etags(ev.tags)) 761 DAMUS.expanded.add(tag[1]) 762 } 763 DAMUS.expanded.add(id) 764 redraw_events(DAMUS) 765 } 766 767 function render_replied_events(model, ev, opts) 768 { 769 if (!(ev.refs && ev.refs.reply)) 770 return "" 771 772 const reply_ev = model.all_events[ev.refs.reply] 773 if (!reply_ev) 774 return "" 775 776 opts.replies = opts.replies == null ? 1 : opts.replies + 1 777 if (!(opts.max_depth == null || opts.replies < opts.max_depth)) 778 return render_thread_collapsed(model, reply_ev, opts) 779 780 opts.is_reply = true 781 return render_event(model, reply_ev, opts) 782 } 783 784 function render_replying_to_chat(model, ev) { 785 const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {} 786 const roomname = chatroom.name || ev.refs.root || "??" 787 const pks = ev.refs.pubkeys || [] 788 const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") 789 const to_users = pks.length === 0 ? "" : ` to ${names}` 790 791 return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` 792 } 793 794 function render_replying_to(model, ev) { 795 if (!(ev.refs && ev.refs.reply)) 796 return "" 797 798 if (ev.kind === 42) 799 return render_replying_to_chat(model, ev) 800 801 let pubkeys = ev.refs.pubkeys || [] 802 if (pubkeys.length === 0 && ev.refs.reply) { 803 const replying_to = model.all_events[ev.refs.reply] 804 if (!replying_to) 805 return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` 806 807 pubkeys = [replying_to.pubkey] 808 } 809 810 const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") 811 812 return ` 813 <span class="replying-to small-txt"> 814 replying to ${names} 815 </span> 816 ` 817 } 818 819 function delete_post_confirm(evid) { 820 if (!confirm("Are you sure you want to delete this post?")) 821 return 822 823 const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() 824 825 if (reason.toLowerCase() === "cancel") 826 return 827 828 delete_post(evid, reason) 829 } 830 831 function render_unknown_event(model, ev) { 832 return "Unknown event" 833 } 834 835 function render_boost(model, ev, opts) { 836 //todo validate content 837 if (!ev.json_content) 838 return render_unknown_event(ev) 839 840 //const profile = model.profiles[ev.pubkey] 841 opts.is_boost_event = true 842 opts.boosted = { 843 pubkey: ev.pubkey, 844 profile: model.profiles[ev.pubkey] 845 } 846 return render_event(model, ev.json_content, opts) 847 //return ` 848 //<div class="boost"> 849 //<div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div> 850 //${render_event(model, ev.json_content, opts)} 851 //</div> 852 //` 853 } 854 855 function shouldnt_render_event(model, ev, opts) { 856 return !opts.is_boost_event && 857 !opts.is_composing && 858 !model.expanded.has(ev.id) && 859 model.rendered[ev.id] 860 } 861 862 function render_deleted_name() { 863 return "???" 864 } 865 866 function render_deleted_pfp() { 867 return `<div class="pfp pfp-normal">😵</div>` 868 } 869 870 function render_comment_body(model, ev, opts) { 871 const can_delete = model.pubkey === ev.pubkey; 872 const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev, can_delete) 873 const show_media = !opts.is_composing 874 875 return ` 876 <div> 877 ${render_replying_to(model, ev)} 878 ${render_boosted_by(model, ev, opts)} 879 </div> 880 <p> 881 ${format_content(ev, show_media)} 882 </p> 883 ${render_reactions(model, ev)} 884 ${bar} 885 ` 886 } 887 888 function render_boosted_by(model, ev, opts) { 889 const b = opts.boosted 890 if (!b) { 891 return "" 892 } 893 // TODO encapsulate username as link/button! 894 return ` 895 <div class="boosted-by">Shared by 896 <span class="username" data-pubkey="${b.pubkey}">${render_name_plain(b.pubkey, b.profile)}</span> 897 </div> 898 ` 899 } 900 901 function render_deleted_comment_body(ev, deleted) { 902 if (deleted.content) { 903 const show_media = false 904 return ` 905 <div class="deleted-comment"> 906 This comment was deleted. Reason: 907 <div class="quote">${format_content(deleted, show_media)}</div> 908 </div> 909 ` 910 } 911 return `<div class="deleted-comment">This comment was deleted</div>` 912 } 913 914 function press_logout() { 915 if (confirm("Are you sure you want to logout?")) { 916 localStorage.clear(); 917 const url = new URL(location.href) 918 url.searchParams.delete("pk") 919 window.location.href = url.toString() 920 } 921 } 922 923 function render_event(model, ev, opts={}) { 924 if (ev.kind === 6) 925 return render_boost(model, ev, opts) 926 if (shouldnt_render_event(model, ev, opts)) 927 return "" 928 delete opts.is_boost_event 929 model.rendered[ev.id] = true 930 const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE 931 const delta = time_delta(new Date().getTime(), ev.created_at*1000) 932 933 const has_bot_line = opts.is_reply 934 const reply_line_bot = (has_bot_line && render_reply_line_bot()) || "" 935 936 const deleted = is_deleted(model, ev.id) 937 if (deleted && !opts.is_reply) 938 return "" 939 940 const replied_events = render_replied_events(model, ev, opts) 941 942 let name = "???" 943 if (!deleted) { 944 name = render_name_plain(ev.pubkey, profile) 945 } 946 947 const has_top_line = replied_events !== "" 948 const border_bottom = has_bot_line ? "" : "bottom-border"; 949 return ` 950 ${replied_events} 951 <div id="ev${ev.id}" class="event ${border_bottom}"> 952 <div class="userpic"> 953 ${render_reply_line_top(has_top_line)} 954 ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} 955 ${reply_line_bot} 956 </div> 957 <div class="event-content"> 958 <div class="info"> 959 <span class="username" data-pubkey="${ev.pubkey}" data-name="${name}"> 960 ${name} 961 </span> 962 <span class="timestamp">${delta}</span> 963 </div> 964 <div class="comment"> 965 ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)} 966 </div> 967 </div> 968 </div> 969 ` 970 } 971 972 function render_pfp(pk, profile, size="normal") { 973 const name = render_name_plain(pk, profile) 974 return `<img class="pfp" title="${name}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">` 975 } 976 977 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])?))?)$/ 978 979 const WORD_REGEX=/\w/ 980 function is_emoji(str) 981 { 982 return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) 983 } 984 985 function is_valid_reaction_content(content) 986 { 987 return content === "+" || content === "" || is_emoji(content) 988 } 989 990 function get_reaction_emoji(ev) { 991 if (ev.content === "+" || ev.content === "") 992 return "❤️" 993 994 return ev.content 995 } 996 997 function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) { 998 const reaction = reactions[our_pubkey] 999 if (!reaction) { 1000 return `onclick="send_reply('${emoji}', '${reacting_to}')"` 1001 } else { 1002 return `onclick="delete_post('${reaction.id}')"` 1003 } 1004 } 1005 1006 function render_reaction_group(model, emoji, reactions, reacting_to) { 1007 const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk])) 1008 1009 let onclick = render_react_onclick(model.pubkey, reacting_to.id, emoji, reactions) 1010 1011 return ` 1012 <span ${onclick} class="reaction-group clickable"> 1013 <span class="reaction-emoji"> 1014 ${emoji} 1015 </span> 1016 ${pfps.join("\n")} 1017 </span> 1018 ` 1019 } 1020 1021 async function delete_post(id, reason) 1022 { 1023 const ev = DAMUS.all_events[id] 1024 if (!ev) 1025 return 1026 1027 const pubkey = await get_pubkey() 1028 let del = await create_deletion_event(pubkey, id, reason) 1029 console.log("deleting", ev) 1030 broadcast_event(del) 1031 } 1032 1033 function render_reaction(model, reaction) { 1034 const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE 1035 let emoji = reaction.content[0] 1036 if (reaction.content === "+" || reaction.content === "") 1037 emoji = "❤️" 1038 1039 return render_pfp(reaction.pubkey, profile, "small") 1040 } 1041 1042 function get_reactions(model, evid) 1043 { 1044 const reactions_set = model.reactions_to[evid] 1045 if (!reactions_set) 1046 return "" 1047 1048 let reactions = [] 1049 for (const id of reactions_set.keys()) { 1050 if (is_deleted(model, id)) 1051 continue 1052 const reaction = model.all_events[id] 1053 if (!reaction) 1054 continue 1055 reactions.push(reaction) 1056 } 1057 1058 const groups = reactions.reduce((grp, r) => { 1059 const e = get_reaction_emoji(r) 1060 grp[e] = grp[e] || {} 1061 grp[e][r.pubkey] = r 1062 return grp 1063 }, {}) 1064 1065 return groups 1066 } 1067 1068 function render_reactions(model, ev) { 1069 const groups = get_reactions(model, ev.id) 1070 let str = "" 1071 1072 for (const emoji of Object.keys(groups)) { 1073 str += render_reaction_group(model, emoji, groups[emoji], ev) 1074 } 1075 1076 return ` 1077 <div class="reactions"> 1078 ${str} 1079 </div> 1080 ` 1081 } 1082 1083 function close_reply() { 1084 const modal = document.querySelector("#reply-modal") 1085 modal.classList.add("closed"); 1086 } 1087 1088 function gather_reply_tags(pubkey, from) { 1089 let tags = [] 1090 for (const tag of from.tags) { 1091 if (tag.length >= 2) { 1092 if (tag[0] === "e") { 1093 tags.push(tag) 1094 } else if (tag[0] === "p" && tag[1] !== pubkey) { 1095 tags.push(tag) 1096 } 1097 } 1098 } 1099 tags.push(["e", from.id, "", "reply"]) 1100 if (from.pubkey !== pubkey) 1101 tags.push(["p", from.pubkey]) 1102 return tags 1103 } 1104 1105 async function create_deletion_event(pubkey, target, content="") 1106 { 1107 const created_at = Math.floor(new Date().getTime() / 1000) 1108 let kind = 5 1109 1110 const tags = [["e", target]] 1111 let del = { pubkey, tags, content, created_at, kind } 1112 1113 del.id = await nostrjs.calculate_id(del) 1114 del = await sign_event(del) 1115 return del 1116 } 1117 1118 async function create_reply(pubkey, content, from) { 1119 const tags = gather_reply_tags(pubkey, from) 1120 const created_at = Math.floor(new Date().getTime() / 1000) 1121 let kind = from.kind 1122 1123 // convert emoji replies into reactions 1124 if (is_valid_reaction_content(content)) 1125 kind = 7 1126 1127 let reply = { pubkey, tags, content, created_at, kind } 1128 1129 reply.id = await nostrjs.calculate_id(reply) 1130 reply = await sign_event(reply) 1131 return reply 1132 } 1133 1134 function get_tag_event(tag) 1135 { 1136 if (tag.length < 2) 1137 return null 1138 1139 if (tag[0] === "e") 1140 return DAMUS.all_events[tag[1]] 1141 1142 if (tag[0] === "p") 1143 return DAMUS.profile_events[tag[1]] 1144 1145 return null 1146 } 1147 1148 async function broadcast_related_events(ev) 1149 { 1150 ev.tags 1151 .reduce((evs, tag) => { 1152 // cap it at something sane 1153 if (evs.length >= 5) 1154 return evs 1155 const ev = get_tag_event(tag) 1156 if (!ev) 1157 return evs 1158 insert_event_sorted(evs, ev) // for uniqueness 1159 return evs 1160 }, []) 1161 .forEach((ev, i) => { 1162 // so we don't get rate limited 1163 setTimeout(() => { 1164 log_debug("broadcasting related event", ev) 1165 broadcast_event(ev) 1166 }, (i+1)*1200) 1167 }) 1168 } 1169 1170 function broadcast_event(ev) { 1171 DAMUS.pool.send(["EVENT", ev]) 1172 } 1173 1174 async function send_reply(content, replying_to) 1175 { 1176 const ev = DAMUS.all_events[replying_to] 1177 if (!ev) 1178 return 1179 1180 const pubkey = await get_pubkey() 1181 let reply = await create_reply(pubkey, content, ev) 1182 1183 broadcast_event(reply) 1184 broadcast_related_events(reply) 1185 } 1186 1187 async function do_send_reply() { 1188 const modal = document.querySelector("#reply-modal") 1189 const replying_to = modal.querySelector("#replying-to") 1190 1191 const evid = replying_to.dataset.evid 1192 const reply_content_el = document.querySelector("#reply-content") 1193 const content = reply_content_el.value 1194 1195 await send_reply(content, evid) 1196 1197 reply_content_el.value = "" 1198 1199 close_reply() 1200 } 1201 1202 function bech32_decode(pubkey) { 1203 const decoded = bech32.decode(pubkey) 1204 const bytes = fromWords(decoded.words) 1205 return nostrjs.hex_encode(bytes) 1206 } 1207 1208 function get_local_state(key) { 1209 if (DAMUS[key] != null) 1210 return DAMUS[key] 1211 1212 return localStorage.getItem(key) 1213 } 1214 1215 function set_local_state(key, val) { 1216 DAMUS[key] = val 1217 localStorage.setItem(key, val) 1218 } 1219 1220 function get_qs(loc=location.href) { 1221 return new URL(loc).searchParams 1222 } 1223 1224 function handle_pubkey(pubkey) { 1225 if (pubkey[0] === "n") 1226 pubkey = bech32_decode(pubkey) 1227 1228 set_local_state('pubkey', pubkey) 1229 1230 return pubkey 1231 } 1232 1233 async function get_pubkey() { 1234 let pubkey = get_local_state('pubkey') 1235 1236 // qs pk overrides stored key 1237 const qs_pk = get_qs().get("pk") 1238 if (qs_pk) 1239 return handle_pubkey(qs_pk) 1240 1241 if (pubkey) 1242 return pubkey 1243 1244 if (window.nostr && window.nostr.getPublicKey) { 1245 const pubkey = await window.nostr.getPublicKey() 1246 console.log("got %s pubkey from nos2x", pubkey) 1247 return pubkey 1248 } 1249 1250 pubkey = prompt("Enter pubkey (hex or npub)") 1251 1252 if (!pubkey) 1253 throw new Error("Need pubkey to continue") 1254 1255 return handle_pubkey(pubkey) 1256 } 1257 1258 function get_privkey() { 1259 let privkey = get_local_state('privkey') 1260 1261 if (privkey) 1262 return privkey 1263 1264 if (!privkey) 1265 privkey = prompt("Enter private key") 1266 1267 if (!privkey) 1268 throw new Error("can't get privkey") 1269 1270 if (privkey[0] === "n") { 1271 privkey = bech32_decode(privkey) 1272 } 1273 1274 set_local_state('privkey', privkey) 1275 1276 return privkey 1277 } 1278 1279 async function sign_id(privkey, id) 1280 { 1281 //const digest = nostrjs.hex_decode(id) 1282 const sig = await nobleSecp256k1.schnorr.sign(id, privkey) 1283 return nostrjs.hex_encode(sig) 1284 } 1285 1286 function reply_to(evid) { 1287 const modal = document.querySelector("#reply-modal") 1288 modal.classList.remove("closed") 1289 const replying_to = modal.querySelector("#replying-to") 1290 1291 replying_to.dataset.evid = evid 1292 const ev = DAMUS.all_events[evid] 1293 replying_to.innerHTML = render_event(DAMUS, ev, {is_composing: true, nobar: true, max_depth: 1}) 1294 } 1295 1296 function render_action_bar(ev, can_delete) { 1297 let delete_html = "" 1298 if (can_delete) 1299 delete_html = `<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"><i class="fa fa-fw fa-trash"></i></a>` 1300 1301 const groups = get_reactions(DAMUS, ev.id) 1302 const like = "❤️" 1303 const likes = groups[like] || {} 1304 const react_onclick = render_react_onclick(DAMUS.pubkey, ev.id, like, likes) 1305 return ` 1306 <div class="action-bar"> 1307 <button class="icon" title="Reply" onclick="reply_to('${ev.id}')"><i class="fa fa-fw fa-comment"></i></a> 1308 <button class="icon react heart" ${react_onclick} title="Like"><i class="fa fa-fw fa-heart"></i></a> 1309 ${delete_html} 1310 </div> 1311 ` 1312 //<button class="icon" title="Share" onclick=""><i class="fa fa-fw fa-link"></i></a> 1313 //<button class="icon" title="View raw Nostr event." onclick=""><i class="fa-solid fa-fw fa-code"></i></a>--> 1314 } 1315 1316 const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i 1317 function is_img_url(path) { 1318 return IMG_REGEX.test(path) 1319 } 1320 1321 const VID_REGEX = /(webm|mp4)$/i 1322 function is_video_url(path) { 1323 return VID_REGEX.test(path) 1324 } 1325 1326 const URL_REGEX = /(https?:\/\/[^\s]+)[,:)]?(\w|$)/g; 1327 function linkify(text, show_media) { 1328 return text.replace(URL_REGEX, function(url) { 1329 const parsed = new URL(url) 1330 if (show_media && is_img_url(parsed.pathname)) 1331 return ` 1332 <a target="_blank" href="${url}"> 1333 <img class="inline-img" src="${url}"/> 1334 </a> 1335 `; 1336 else if (show_media && is_video_url(parsed.pathname)) 1337 return ` 1338 <video controls class="inline-img" /> 1339 <source src="${url}"> 1340 </video> 1341 `; 1342 else 1343 return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; 1344 }) 1345 } 1346 1347 function convert_quote_blocks(content, show_media) 1348 { 1349 const split = content.split("\n") 1350 let blockin = false 1351 return split.reduce((str, line) => { 1352 if (line !== "" && line[0] === '>') { 1353 if (!blockin) { 1354 str += "<span class='quote'>" 1355 blockin = true 1356 } 1357 str += linkify(sanitize(line.slice(1)), show_media) 1358 } else { 1359 if (blockin) { 1360 blockin = false 1361 str += "</span>" 1362 } 1363 str += linkify(sanitize(line), show_media) 1364 } 1365 return str + "<br/>" 1366 }, "") 1367 } 1368 1369 function get_content_warning(tags) 1370 { 1371 for (const tag of tags) { 1372 if (tag.length >= 1 && tag[0] === "content-warning") 1373 return tag[1] || "" 1374 } 1375 1376 return null 1377 } 1378 1379 function toggle_content_warning(e) 1380 { 1381 const el = e.target 1382 const id = el.id.split("_")[1] 1383 const ev = DAMUS.all_events[id] 1384 1385 if (!ev) { 1386 log_debug("could not find content-warning event", id) 1387 return 1388 } 1389 1390 DAMUS.cw_open[id] = el.open 1391 } 1392 1393 function format_content(ev, show_media) 1394 { 1395 if (ev.kind === 7) { 1396 if (ev.content === "" || ev.content === "+") 1397 return "❤️" 1398 return sanitize(ev.content.trim()) 1399 } 1400 1401 const content = ev.content.trim() 1402 const body = convert_quote_blocks(content, show_media) 1403 1404 let cw = get_content_warning(ev.tags) 1405 if (cw !== null) { 1406 let cwHTML = "Content Warning" 1407 if (cw === "") { 1408 cwHTML += "." 1409 } else { 1410 cwHTML += `: "<span>${cw}</span>".` 1411 } 1412 const open = !!DAMUS.cw_open[ev.id]? "open" : "" 1413 return ` 1414 <details class="cw" id="cw_${ev.id}" ${open}> 1415 <summary>${cwHTML}</summary> 1416 ${body} 1417 </details> 1418 ` 1419 } 1420 1421 return body 1422 } 1423 1424 function sanitize(content) 1425 { 1426 if (!content) 1427 return "" 1428 return content.replaceAll("<","<").replaceAll(">",">") 1429 } 1430 1431 function robohash(pk) { 1432 return "https://robohash.org/" + pk 1433 } 1434 1435 function get_picture(pk, profile) { 1436 if (profile.resolved_picture) 1437 return profile.resolved_picture 1438 profile.resolved_picture = sanitize(profile.picture) || robohash(pk) 1439 return profile.resolved_picture 1440 } 1441 1442 function render_name_plain(pk, profile=DEFAULT_PROFILE) 1443 { 1444 if (profile.sanitized_name) 1445 return profile.sanitized_name 1446 1447 const display_name = profile.display_name || profile.user 1448 const username = profile.name || "anon" 1449 const name = display_name || username 1450 1451 profile.sanitized_name = sanitize(name) 1452 return profile.sanitized_name 1453 } 1454 1455 function render_pubkey(pk) 1456 { 1457 return pk.slice(-8) 1458 } 1459 1460 function render_username(pk, profile) 1461 { 1462 return (profile && profile.name) || render_pubkey(pk) 1463 } 1464 1465 function render_mentioned_name(pk, profile) { 1466 return `<span class="username">@${render_username(pk, profile)}</span>` 1467 } 1468 1469 function render_name(pk, profile) { 1470 return `<div class="username">${render_name_plain(pk, profile)}</div>` 1471 } 1472 1473 function time_delta(current, previous) { 1474 var msPerMinute = 60 * 1000; 1475 var msPerHour = msPerMinute * 60; 1476 var msPerDay = msPerHour * 24; 1477 var msPerMonth = msPerDay * 30; 1478 var msPerYear = msPerDay * 365; 1479 1480 var elapsed = current - previous; 1481 1482 if (elapsed < msPerMinute) { 1483 return Math.round(elapsed/1000) + ' seconds ago'; 1484 } 1485 1486 else if (elapsed < msPerHour) { 1487 return Math.round(elapsed/msPerMinute) + ' minutes ago'; 1488 } 1489 1490 else if (elapsed < msPerDay ) { 1491 return Math.round(elapsed/msPerHour ) + ' hours ago'; 1492 } 1493 1494 else if (elapsed < msPerMonth) { 1495 return Math.round(elapsed/msPerDay) + ' days ago'; 1496 } 1497 1498 else if (elapsed < msPerYear) { 1499 return Math.round(elapsed/msPerMonth) + ' months ago'; 1500 } 1501 1502 else { 1503 return Math.round(elapsed/msPerYear ) + ' years ago'; 1504 } 1505 } 1506 1507 function toggle_cw(el) { 1508 el.classList.toggle("active"); 1509 const isOn = el.classList.contains("active"); 1510 const input = el.parentElement.querySelector("input.cw"); 1511 input.classList.toggle("hide", !isOn); 1512 }