damus.js (36187B)
1 2 let DAMUS 3 4 const BOOTSTRAP_RELAYS = [ 5 "wss://relay.damus.io", 6 "wss://nostr-relay.wlvs.space", 7 "wss://nostr-pub.wellorder.net" 8 ] 9 10 const DEFAULT_PROFILE = { 11 name: "anon", 12 display_name: "Anonymous", 13 } 14 15 function insert_event_sorted(evs, new_ev) { 16 for (let i = 0; i < evs.length; i++) { 17 const ev = evs[i] 18 19 if (new_ev.id === ev.id) { 20 return false 21 } 22 23 if (new_ev.created_at > ev.created_at) { 24 evs.splice(i, 0, new_ev) 25 return true 26 } 27 } 28 29 evs.push(new_ev) 30 return true 31 } 32 33 function init_contacts() { 34 return { 35 event: null, 36 friends: new Set(), 37 friend_of_friends: new Set(), 38 } 39 } 40 41 function init_timeline(name) { 42 return { 43 name, 44 events: [], 45 rendered: new Set(), 46 depths: {}, 47 expanded: new Set(), 48 } 49 } 50 51 function init_home_model() { 52 return { 53 done_init: {}, 54 notifications: 0, 55 max_depth: 2, 56 all_events: {}, 57 reactions_to: {}, 58 chatrooms: {}, 59 unknown_ids: {}, 60 unknown_pks: {}, 61 deletions: {}, 62 but_wait_theres_more: 0, 63 cw_open: {}, 64 views: { 65 home: init_timeline('home'), 66 explore: { 67 ...init_timeline('explore'), 68 seen: new Set(), 69 }, 70 notifications: { 71 ...init_timeline('notifications'), 72 max_depth: 1, 73 }, 74 profile: init_timeline('profile'), 75 thread: init_timeline('thread'), 76 }, 77 pow: 0, // pow difficulty target 78 deleted: {}, 79 profiles: {}, 80 profile_events: {}, 81 last_event_of_kind: {}, 82 contacts: init_contacts() 83 } 84 } 85 86 function update_favicon(path) 87 { 88 let link = document.querySelector("link[rel~='icon']"); 89 const head = document.getElementsByTagName('head')[0] 90 91 if (!link) { 92 link = document.createElement('link'); 93 link.rel = 'icon'; 94 head.appendChild(link); 95 } 96 97 link.href = path; 98 } 99 100 // update_title updates the document title & visual indicators based on if the 101 // number of notifications that are unseen by the user. 102 function update_title(model) { 103 // TODO rename update_title to update_notification_state or similar 104 // TODO only clear notifications once they have seen all targeted events 105 if (document.visibilityState === 'visible') { 106 model.notifications = 0 107 } 108 109 const num = model.notifications 110 const has_notes = num !== 0 111 document.title = has_notes ? `(${num}) Damus` : "Damus"; 112 update_favicon(has_notes ? "img/damus_notif.svg" : "img/damus.svg"); 113 update_notification_markers(has_notes) 114 } 115 116 function notice_chatroom(state, id) 117 { 118 if (!state.chatrooms[id]) 119 state.chatrooms[id] = {} 120 } 121 122 async function damus_web_init() 123 { 124 init_message_textareas(); 125 126 let tries = 0; 127 function init() { 128 // only wait for 500ms max 129 const max_wait = 500 130 const interval = 20 131 if (window.nostr || tries >= (max_wait/interval)) { 132 console.info("init after", tries); 133 damus_web_init_ready(); 134 return; 135 } 136 tries++; 137 setTimeout(init, interval); 138 } 139 init(); 140 } 141 142 async function damus_web_init_ready() 143 { 144 const model = init_home_model() 145 DAMUS = model 146 model.pubkey = await get_pubkey() 147 if (!model.pubkey) 148 return 149 const {RelayPool} = nostrjs 150 const pool = RelayPool(BOOTSTRAP_RELAYS) 151 const now = (new Date().getTime()) / 1000 152 153 const ids = { 154 comments: "comments",//uuidv4(), 155 profiles: "profiles",//uuidv4(), 156 explore: "explore",//uuidv4(), 157 refevents: "refevents",//uuidv4(), 158 account: "account",//uuidv4(), 159 home: "home",//uuidv4(), 160 contacts: "contacts",//uuidv4(), 161 notifications: "notifications",//uuidv4(), 162 unknowns: "unknowns",//uuidv4(), 163 dms: "dms",//uuidv4(), 164 } 165 166 model.ids = ids 167 168 model.pool = pool 169 170 load_cache(model) 171 model.view_el = document.querySelector("#view") 172 173 switch_view('home') 174 175 document.addEventListener('visibilitychange', () => { 176 update_title(model) 177 }) 178 179 pool.on('open', (relay) => { 180 //let authors = followers 181 // TODO: fetch contact list 182 log_debug("relay connected", relay.url) 183 184 if (!model.done_init[relay]) { 185 send_initial_filters(ids.account, model.pubkey, relay) 186 } else { 187 send_home_filters(model, relay) 188 } 189 //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) 190 }); 191 192 pool.on('event', (relay, sub_id, ev) => { 193 handle_home_event(model, relay, sub_id, ev) 194 }) 195 196 pool.on('notice', (relay, notice) => { 197 log_debug("NOTICE", relay, notice) 198 }) 199 200 pool.on('eose', async (relay, sub_id) => { 201 if (sub_id === ids.home) { 202 //log_debug("got home EOSE from %s", relay.url) 203 const events = model.views.home.events 204 handle_comments_loaded(ids, model, events, relay) 205 } else if (sub_id === ids.profiles) { 206 //log_debug("got profiles EOSE from %s", relay.url) 207 const view = get_current_view() 208 handle_profiles_loaded(ids, model, view, relay) 209 } else if (sub_id === ids.unknowns) { 210 model.pool.unsubscribe(ids.unknowns, relay) 211 } 212 }) 213 214 return pool 215 } 216 217 function process_reaction_event(model, ev) 218 { 219 if (!is_valid_reaction_content(ev.content)) 220 return 221 222 let last = {} 223 224 for (const tag of ev.tags) { 225 if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) 226 last[tag[0]] = tag[1] 227 } 228 229 if (last.e) { 230 model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() 231 model.reactions_to[last.e].add(ev.id) 232 } 233 } 234 235 function process_chatroom_event(model, ev) 236 { 237 try { 238 model.chatrooms[ev.id] = sanitize_obj(JSON.parse(ev.content)) 239 } catch (err) { 240 log_debug("error processing chatroom creation event", ev, err) 241 } 242 } 243 244 function process_deletion_event(model, ev) 245 { 246 for (const tag of ev.tags) { 247 if (tag.length >= 2 && tag[0] === "e") { 248 const evid = tag[1] 249 250 // we've already recorded this one as a valid deleted 251 // event we can just ignore it 252 if (model.deleted[evid]) 253 continue 254 255 let ds = model.deletions[evid] = 256 (model.deletions[evid] || new Set()) 257 258 // add the deletion event id to the deletion set of 259 // this event we will use this to determine if this 260 // event is valid later in case we don't have the 261 // deleted event yet. 262 ds.add(ev.id) 263 } 264 } 265 } 266 267 function is_deleted(model, evid) 268 { 269 // we've already know it's deleted 270 if (model.deleted[evid]) 271 return model.deleted[evid] 272 273 const ev = model.all_events[evid] 274 if (!ev) 275 return false 276 277 // all deletion events 278 const ds = model.deletions[ev.id] 279 if (!ds) 280 return false 281 282 // find valid deletion events 283 for (const id of ds.keys()) { 284 const d_ev = model.all_events[id] 285 if (!d_ev) 286 continue 287 288 // only allow deletes from the user who created it 289 if (d_ev.pubkey === ev.pubkey) { 290 model.deleted[ev.id] = d_ev 291 log_debug("received deletion for", ev) 292 // clean up deletion data that we don't need anymore 293 delete model.deletions[ev.id] 294 return true 295 } else { 296 log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) 297 } 298 } 299 300 return false 301 } 302 303 function has_profile(damus, pk) { 304 return pk in damus.profiles 305 } 306 307 function has_event(damus, evid) { 308 return evid in damus.all_events 309 } 310 311 const ID_REG = /^[a-f0-9]{64}$/ 312 function is_valid_id(evid) 313 { 314 return ID_REG.test(evid) 315 } 316 317 function make_unk(hint, ev) 318 { 319 const attempts = 0 320 const parent_created = ev.created_at 321 322 if (hint && hint !== "") 323 return {attempts, hint: hint.trim().toLowerCase(), parent_created} 324 325 return {attempts, parent_created} 326 } 327 328 function notice_unknown_ids(damus, ev) 329 { 330 // make sure this event itself is removed from unknowns 331 if (ev.kind === 0) 332 delete damus.unknown_pks[ev.pubkey] 333 delete damus.unknown_ids[ev.id] 334 335 let got_some = false 336 337 for (const tag of ev.tags) { 338 if (tag.length >= 2) { 339 if (tag[0] === "p") { 340 const pk = tag[1] 341 if (!has_profile(damus, pk) && is_valid_id(pk)) { 342 got_some = true 343 damus.unknown_pks[pk] = make_unk(tag[2], ev) 344 } 345 } else if (tag[0] === "e") { 346 const evid = tag[1] 347 if (!has_event(damus, evid) && is_valid_id(evid)) { 348 got_some = true 349 damus.unknown_ids[evid] = make_unk(tag[2], ev) 350 } 351 } 352 } 353 } 354 355 return got_some 356 } 357 358 function gather_unknown_hints(damus, pks, evids) 359 { 360 let relays = new Set() 361 362 for (const pk of pks) { 363 const unk = damus.unknown_pks[pk] 364 if (unk && unk.hint && unk.hint !== "") 365 relays.add(unk.hint) 366 } 367 368 for (const evid of evids) { 369 const unk = damus.unknown_ids[evid] 370 if (unk && unk.hint && unk.hint !== "") 371 relays.add(unk.hint) 372 } 373 374 return Array.from(relays) 375 } 376 377 function get_non_expired_unknowns(unks, type) 378 { 379 const MAX_ATTEMPTS = 2 380 381 function sort_parent_created(a_id, b_id) { 382 const a = unks[a_id] 383 const b = unks[b_id] 384 return b.parent_created - a.parent_created 385 } 386 387 let new_expired = 0 388 const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => { 389 if (ids.length >= 255) 390 return ids 391 392 const unk = unks[unk_id] 393 if (unk.attempts >= MAX_ATTEMPTS) { 394 if (!unk.expired) { 395 unk.expired = true 396 new_expired++ 397 } 398 return ids 399 } 400 401 unk.attempts++ 402 403 ids.push(unk_id) 404 return ids 405 }, []) 406 407 if (new_expired !== 0) 408 log_debug("Gave up looking for %d %s", new_expired, type) 409 410 return ids 411 } 412 413 function fetch_unknown_events(damus) 414 { 415 let filters = [] 416 417 const pks = get_non_expired_unknowns(damus.unknown_pks, 'profiles') 418 const evids = get_non_expired_unknowns(damus.unknown_ids, 'events') 419 420 const relays = gather_unknown_hints(damus, pks, evids) 421 422 for (const relay of relays) { 423 if (!damus.pool.has(relay)) { 424 log_debug("adding %s to relays to fetch unknown events", relay) 425 damus.pool.add(relay) 426 } 427 } 428 429 if (evids.length !== 0) { 430 const unk_kinds = [1,5,6,7,40,42] 431 filters.push({ids: evids, kinds: unk_kinds}) 432 filters.push({"#e": evids, kinds: [1,42], limit: 100}) 433 } 434 435 if (pks.length !== 0) 436 filters.push({authors: pks, kinds:[0]}) 437 438 if (filters.length === 0) 439 return 440 441 log_debug("fetching unknowns", filters) 442 damus.pool.subscribe('unknowns', filters) 443 } 444 445 function shuffle(arr) 446 { 447 let i = arr.length; 448 while (--i > 0) { 449 let randIndex = Math.floor(Math.random() * (i + 1)); 450 [arr[randIndex], arr[i]] = [arr[i], arr[randIndex]]; 451 } 452 return arr; 453 } 454 455 456 function schedule_unknown_refetch(damus) 457 { 458 const INTERVAL = 5000 459 if (!damus.unknown_timer) { 460 log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000) 461 462 damus.unknown_timer = setTimeout(() => { 463 fetch_unknown_events(damus) 464 465 setTimeout(() => { 466 delete damus.unknown_timer 467 if (damus.but_wait_theres_more > 0) { 468 damus.but_wait_theres_more = 0 469 schedule_unknown_refetch(damus) 470 } 471 }, INTERVAL) 472 }, INTERVAL) 473 474 fetch_unknown_events(damus) 475 } else { 476 damus.but_wait_theres_more++ 477 } 478 } 479 480 function process_event(damus, ev) 481 { 482 ev.refs = determine_event_refs(ev.tags) 483 const notified = was_pubkey_notified(damus.pubkey, ev) 484 ev.notified = notified 485 486 const got_some_unknowns = notice_unknown_ids(damus, ev) 487 if (got_some_unknowns) 488 schedule_unknown_refetch(damus) 489 490 ev.pow = calculate_pow(ev) 491 492 if (ev.kind === 7) 493 process_reaction_event(damus, ev) 494 else if (ev.kind === 42 && ev.refs && ev.refs.root) 495 notice_chatroom(damus, ev.refs.root) 496 else if (ev.kind === 40) 497 process_chatroom_event(damus, ev) 498 else if (ev.kind === 5) 499 process_deletion_event(damus, ev) 500 else if (ev.kind === 0) 501 process_profile_event(damus, ev) 502 else if (ev.kind === 3) 503 process_contact_event(damus, ev) 504 505 const last_notified = get_local_state('last_notified_date') 506 if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { 507 set_local_state('last_notified_date', new Date().getTime()) 508 damus.notifications++ 509 update_title(damus) 510 } 511 } 512 513 function was_pubkey_notified(pubkey, ev) 514 { 515 if (!(ev.kind === 1 || ev.kind === 42)) 516 return false 517 518 if (ev.pubkey === pubkey) 519 return false 520 521 for (const tag of ev.tags) { 522 if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) 523 return true 524 } 525 526 return false 527 } 528 529 function should_add_to_notification_timeline(our_pk, contacts, ev, pow) 530 { 531 if (!should_add_to_timeline(ev)) 532 return false 533 534 // TODO: add items that don't pass spam filter to "message requests" 535 // Then we will need a way to whitelist people as an alternative to 536 // following them 537 return passes_spam_filter(contacts, ev, pow) 538 } 539 540 function should_add_to_explore_timeline(contacts, view, ev, pow) 541 { 542 if (!should_add_to_timeline(ev)) 543 return false 544 545 if (view.seen.has(ev.pubkey)) 546 return false 547 548 // hide friends for 0-pow situations 549 if (pow === 0 && contacts.friends.has(ev.pubkey)) 550 return false 551 552 return passes_spam_filter(contacts, ev, pow) 553 } 554 555 function get_current_view() 556 { 557 // TODO resolve memory & html descriptencies 558 // Currently there is tracking of which divs are visible in HTML/CSS and 559 // which is active in memory, simply resolve this by finding the visible 560 // element instead of tracking it in memory (or remove dom elements). This 561 // would simplify state tracking IMO - Thomas 562 return DAMUS.views[DAMUS.current_view] 563 } 564 565 function handle_redraw_logic(model, view_name) 566 { 567 const view = model.views[view_name] 568 if (view.redraw_timer) 569 clearTimeout(view.redraw_timer) 570 view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600) 571 } 572 573 function schedule_save_events(damus) 574 { 575 if (damus.save_timer) 576 clearTimeout(damus.save_timer) 577 damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000) 578 } 579 580 function is_valid_time(now_sec, created_at) 581 { 582 // don't count events far in the future 583 if (created_at - now_sec >= 120) { 584 return false 585 } 586 return true 587 } 588 589 function max(a, b) { 590 return a > b ? a : b 591 } 592 593 function calculate_last_of_kind(evs) 594 { 595 const now_sec = new Date().getTime() / 1000 596 return Object.keys(evs).reduce((obj, evid) => { 597 const ev = evs[evid] 598 if (!is_valid_time(now_sec, ev.created_at)) 599 return obj 600 const prev = obj[ev.kind] || 0 601 obj[ev.kind] = get_since_time(max(ev.created_at, prev)) 602 return obj 603 }, {}) 604 } 605 606 function load_events(damus) 607 { 608 if (!('event_cache' in localStorage)) 609 return {} 610 const cached = JSON.parse(localStorage.getItem('event_cache')) 611 612 return cached.reduce((obj, ev) => { 613 obj[ev.id] = ev 614 process_event(damus, ev) 615 return obj 616 }, {}) 617 } 618 619 function load_cache(damus) 620 { 621 damus.all_events = load_events(damus) 622 load_timelines(damus) 623 } 624 625 function save_cache(damus) 626 { 627 save_events(damus) 628 save_timelines(damus) 629 } 630 631 function save_events(damus) 632 { 633 const keys = Object.keys(damus.all_events) 634 const MAX_KINDS = { 635 1: 2000, 636 0: 2000, 637 638 6: 100, 639 4: 100, 640 5: 100, 641 7: 100, 642 } 643 644 let counts = {} 645 646 let cached = keys.map((key) => { 647 const ev = damus.all_events[key] 648 const {sig, pubkey, content, tags, kind, created_at, id} = ev 649 return {sig, pubkey, content, tags, kind, created_at, id} 650 }) 651 652 cached.sort((a,b) => b.created_at - a.created_at) 653 cached = cached.reduce((cs, ev) => { 654 counts[ev.kind] = (counts[ev.kind] || 0)+1 655 if (counts[ev.kind] < MAX_KINDS[ev.kind]) 656 cs.push(ev) 657 return cs 658 }, []) 659 660 log_debug('saving all events to local storage', cached.length) 661 662 localStorage.setItem('event_cache', JSON.stringify(cached)) 663 } 664 665 function save_timelines(damus) 666 { 667 const views = Object.keys(damus.views).reduce((obj, view_name) => { 668 const view = damus.views[view_name] 669 obj[view_name] = view.events.map(e => e.id).slice(0,100) 670 return obj 671 }, {}) 672 localStorage.setItem('views', JSON.stringify(views)) 673 } 674 675 function load_timelines(damus) 676 { 677 if (!('views' in localStorage)) 678 return 679 const stored_views = JSON.parse(localStorage.getItem('views')) 680 for (const view_name of Object.keys(damus.views)) { 681 const view = damus.views[view_name] 682 view.events = (stored_views[view_name] || []).reduce((evs, evid) => { 683 const ev = damus.all_events[evid] 684 if (ev) evs.push(ev) 685 return evs 686 }, []) 687 } 688 } 689 690 function handle_home_event(model, relay, sub_id, ev) { 691 const ids = model.ids 692 693 // ignore duplicates 694 if (!has_event(model, ev.id)) { 695 model.all_events[ev.id] = ev 696 process_event(model, ev) 697 schedule_save_events(model) 698 } 699 700 ev = model.all_events[ev.id] 701 702 let is_new = true 703 switch (sub_id) { 704 case model.ids.explore: 705 const view = model.views.explore 706 707 // show more things in explore timeline 708 if (should_add_to_explore_timeline(model.contacts, view, ev, model.pow)) { 709 view.seen.add(ev.pubkey) 710 is_new = insert_event_sorted(view.events, ev) 711 } 712 713 if (is_new) 714 handle_redraw_logic(model, 'explore') 715 break; 716 717 case model.ids.notifications: 718 if (should_add_to_notification_timeline(model.pubkey, model.contacts, ev, model.pow)) 719 is_new = insert_event_sorted(model.views.notifications.events, ev) 720 721 if (is_new) 722 handle_redraw_logic(model, 'notifications') 723 break; 724 725 case model.ids.home: 726 if (should_add_to_timeline(ev)) 727 is_new = insert_event_sorted(model.views.home.events, ev) 728 729 if (is_new) 730 handle_redraw_logic(model, 'home') 731 break; 732 case model.ids.account: 733 switch (ev.kind) { 734 case 3: 735 model.done_init[relay] = true 736 model.pool.unsubscribe(model.ids.account, relay) 737 send_home_filters(model, relay) 738 break 739 } 740 break 741 case model.ids.profiles: 742 break 743 } 744 } 745 746 function sanitize_obj(obj) { 747 for (const key of Object.keys(obj)) { 748 obj[key] = sanitize(obj[key]) 749 } 750 751 return obj 752 } 753 754 function process_profile_event(model, ev) { 755 const prev_ev = model.all_events[model.profile_events[ev.pubkey]] 756 if (prev_ev && prev_ev.created_at > ev.created_at) 757 return 758 759 model.profile_events[ev.pubkey] = ev.id 760 try { 761 model.profiles[ev.pubkey] = sanitize_obj(JSON.parse(ev.content)) 762 } catch(e) { 763 log_debug("failed to parse profile contents", ev) 764 } 765 } 766 767 function send_initial_filters(account_id, pubkey, relay) { 768 const filter = {authors: [pubkey], kinds: [3], limit: 1} 769 //console.log("sending initial filter", filter) 770 relay.subscribe(account_id, filter) 771 } 772 773 function send_home_filters(model, relay) { 774 const ids = model.ids 775 const friends = contacts_friend_list(model.contacts) 776 friends.push(model.pubkey) 777 778 const contacts_filter = {kinds: [0], authors: friends} 779 const dms_filter = {kinds: [4], "#p": [ model.pubkey ], limit: 100} 780 const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 100} 781 782 const standard_kinds = [1,42,5,6,7] 783 784 const home_filter = {kinds: standard_kinds, authors: friends, limit: 500} 785 786 // TODO: include pow+fof spam filtering in notifications query 787 const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100} 788 789 let home_filters = [home_filter] 790 let notifications_filters = [notifications_filter] 791 let contacts_filters = [contacts_filter] 792 let dms_filters = [dms_filter, our_dms_filter] 793 794 let last_of_kind = {} 795 if (relay) { 796 last_of_kind = 797 model.last_event_of_kind[relay] = 798 model.last_event_of_kind[relay] || calculate_last_of_kind(model.all_events) 799 800 log_debug("last_of_kind", last_of_kind) 801 } 802 803 update_filters_with_since(last_of_kind, home_filters) 804 update_filters_with_since(last_of_kind, contacts_filters) 805 update_filters_with_since(last_of_kind, notifications_filters) 806 update_filters_with_since(last_of_kind, dms_filters) 807 808 const subto = relay? [relay] : undefined 809 model.pool.subscribe(ids.home, home_filters, subto) 810 model.pool.subscribe(ids.contacts, contacts_filters, subto) 811 model.pool.subscribe(ids.notifications, notifications_filters, subto) 812 model.pool.subscribe(ids.dms, dms_filters, subto) 813 } 814 815 function update_filter_with_since(last_of_kind, filter) { 816 const kinds = filter.kinds || [] 817 let initial = null 818 let earliest = kinds.reduce((earliest, kind) => { 819 const last_created_at = last_of_kind[kind] 820 let since = get_since_time(last_created_at) 821 822 if (!earliest) { 823 if (since === null) 824 return null 825 826 return since 827 } 828 829 if (since === null) 830 return earliest 831 832 return since < earliest ? since : earliest 833 834 }, initial) 835 836 if (earliest) 837 filter.since = earliest 838 } 839 840 function update_filters_with_since(last_of_kind, filters) { 841 for (const filter of filters) { 842 update_filter_with_since(last_of_kind, filter) 843 } 844 } 845 846 function contacts_friend_list(contacts) { 847 return Array.from(contacts.friends) 848 } 849 850 function contacts_friendosphere(contacts) { 851 let s = new Set() 852 let fs = [] 853 854 for (const friend of contacts.friends.keys()) { 855 fs.push(friend) 856 s.add(friend) 857 } 858 859 for (const friend of contacts.friend_of_friends.keys()) { 860 if (!s.has(friend)) 861 fs.push(friend) 862 } 863 864 return fs 865 } 866 867 function process_contact_event(model, ev) { 868 load_our_contacts(model.contacts, model.pubkey, ev) 869 load_our_relays(model.pubkey, model.pool, ev) 870 add_contact_if_friend(model.contacts, ev) 871 } 872 873 function add_contact_if_friend(contacts, ev) { 874 if (!contact_is_friend(contacts, ev.pubkey)) 875 return 876 877 add_friend_contact(contacts, ev) 878 } 879 880 function contact_is_friend(contacts, pk) { 881 return contacts.friends.has(pk) 882 } 883 884 function add_friend_contact(contacts, contact) { 885 contacts.friends.add(contact.pubkey) 886 887 for (const tag of contact.tags) { 888 if (tag.length >= 2 && tag[0] == "p") { 889 if (!contact_is_friend(contacts, tag[1])) 890 contacts.friend_of_friends.add(tag[1]) 891 } 892 } 893 } 894 895 function get_view_el(name) 896 { 897 return DAMUS.view_el.querySelector(`#${name}-view`) 898 } 899 900 function switch_view(name, opts={}) 901 { 902 if (name === DAMUS.current_view) { 903 log_debug("Not switching to '%s', we are already there", name) 904 return 905 } 906 907 const last = get_current_view() 908 if (!last) { 909 // render initial 910 DAMUS.current_view = name 911 redraw_timeline_events(DAMUS, name) 912 return 913 } 914 915 log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view) 916 917 DAMUS.current_view = name 918 const current = get_current_view() 919 const last_el = get_view_el(last.name) 920 const current_el = get_view_el(current.name) 921 922 if (last_el) 923 last_el.classList.add("hide"); 924 925 // TODO accomodate views that do not render events 926 // TODO find out if having multiple event divs is slow 927 //redraw_timeline_events(DAMUS, name) 928 929 find_node("#nav > div[data-active]").dataset.active = name; 930 931 if (current_el) 932 current_el.classList.remove("hide"); 933 } 934 935 function load_our_relays(our_pubkey, pool, ev) { 936 if (ev.pubkey != our_pubkey) 937 return 938 939 let relays 940 try { 941 relays = JSON.parse(ev.content) 942 } catch (e) { 943 log_debug("error loading relays", e) 944 return 945 } 946 947 for (const relay of Object.keys(relays)) { 948 if (!pool.has(relay)) { 949 log_debug("adding relay", relay) 950 pool.add(relay) 951 } 952 } 953 } 954 955 function load_our_contacts(contacts, our_pubkey, ev) { 956 if (ev.pubkey !== our_pubkey) 957 return 958 959 contacts.event = ev 960 961 for (const tag of ev.tags) { 962 if (tag.length > 1 && tag[0] === "p") { 963 contacts.friends.add(tag[1]) 964 } 965 } 966 } 967 968 function handle_profiles_loaded(ids, model, view, relay) { 969 // stop asking for profiles 970 model.pool.unsubscribe(ids.profiles, relay) 971 972 //redraw_events(model, view) 973 redraw_my_pfp(model) 974 975 const prefix = difficulty_to_prefix(model.pow) 976 const fofs = Array.from(model.contacts.friend_of_friends) 977 const standard_kinds = [1,42,5,6,7] 978 const now = new Date().getTime() / 1000; 979 980 let pow_filter = {kinds: standard_kinds, limit: 50, until: now} 981 if (model.pow > 0) 982 pow_filter.ids = [ prefix ] 983 984 let explore_filters = [ pow_filter ] 985 986 if (fofs.length > 0) { 987 explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50}) 988 } 989 990 model.pool.subscribe(ids.explore, explore_filters, relay) 991 } 992 993 function redraw_my_pfp(model, force = false) { 994 const p = model.profiles[model.pubkey] 995 if (!p) return; 996 const html = render_pfp(model.pubkey, p); 997 const el = document.querySelector(".my-userpic") 998 if (!force && el.dataset.loaded) return; 999 el.dataset.loaded = true; 1000 el.innerHTML = html; 1001 } 1002 1003 function debounce(f, interval) { 1004 let timer = null; 1005 let first = true; 1006 1007 return (...args) => { 1008 clearTimeout(timer); 1009 return new Promise((resolve) => { 1010 timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); 1011 first = false 1012 }); 1013 }; 1014 } 1015 1016 // load profiles after comment notes are loaded 1017 function handle_comments_loaded(ids, model, events, relay) 1018 { 1019 const pubkeys = events.reduce((s, ev) => { 1020 s.add(ev.pubkey) 1021 for (const tag of ev.tags) { 1022 if (tag.length >= 2 && tag[0] === "p") { 1023 if (!model.profile_events[tag[1]]) 1024 s.add(tag[1]) 1025 } 1026 } 1027 return s 1028 }, new Set()) 1029 const authors = Array.from(pubkeys) 1030 1031 // load profiles and noticed chatrooms 1032 const profile_filter = {kinds: [0,3], authors: authors} 1033 1034 let filters = [] 1035 1036 if (authors.length > 0) 1037 filters.push(profile_filter) 1038 1039 if (filters.length === 0) { 1040 log_debug("No profiles filters to request...") 1041 return 1042 } 1043 1044 //console.log("subscribe", profiles_id, filter, relay) 1045 log_debug("subscribing to profiles on %s", relay.url) 1046 model.pool.subscribe(ids.profiles, filters, relay) 1047 } 1048 1049 function redraw_events(damus, view) { 1050 //log_debug("redrawing events for", view) 1051 view.rendered = new Set() 1052 1053 const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`) 1054 events_el.innerHTML = render_events(damus, view) 1055 } 1056 1057 function redraw_timeline_events(damus, name) { 1058 const view = DAMUS.views[name] 1059 const events_el = damus.view_el.querySelector(`#${name}-view > .events`) 1060 1061 if (view.events.length > 0) { 1062 redraw_events(damus, view) 1063 } else { 1064 events_el.innerHTML = render_loading_spinner() 1065 } 1066 } 1067 1068 async function send_post() { 1069 const input_el = document.querySelector("#post-input") 1070 const cw_el = document.querySelector("#content-warning-input") 1071 1072 const cw = cw_el.value 1073 const content = input_el.value 1074 const created_at = Math.floor(new Date().getTime() / 1000) 1075 const kind = 1 1076 const tags = cw ? [["content-warning", cw]] : [] 1077 const pubkey = await get_pubkey() 1078 const {pool} = DAMUS 1079 1080 let post = { pubkey, tags, content, created_at, kind } 1081 1082 post.id = await nostrjs.calculate_id(post) 1083 post = await sign_event(post) 1084 1085 pool.send(["EVENT", post]) 1086 1087 input_el.value = "" 1088 cw_el.value = "" 1089 post_input_changed(input_el) 1090 } 1091 1092 async function sign_event(ev) { 1093 if (window.nostr && window.nostr.signEvent) { 1094 const signed = await window.nostr.signEvent(ev) 1095 if (typeof signed === 'string') { 1096 ev.sig = signed 1097 return ev 1098 } 1099 return signed 1100 } 1101 1102 const privkey = get_privkey() 1103 ev.sig = await sign_id(privkey, ev.id) 1104 return ev 1105 } 1106 1107 function determine_event_refs_positionally(pubkeys, ids) 1108 { 1109 if (ids.length === 1) 1110 return {root: ids[0], reply: ids[0], pubkeys} 1111 else if (ids.length >= 2) 1112 return {root: ids[0], reply: ids[1], pubkeys} 1113 1114 return {pubkeys} 1115 } 1116 1117 function determine_event_refs(tags) { 1118 let positional_ids = [] 1119 let pubkeys = [] 1120 let root 1121 let reply 1122 let i = 0 1123 1124 for (const tag of tags) { 1125 if (tag.length >= 4 && tag[0] == "e") { 1126 positional_ids.push(tag[1]) 1127 if (tag[3] === "root") { 1128 root = tag[1] 1129 } else if (tag[3] === "reply") { 1130 reply = tag[1] 1131 } 1132 } else if (tag.length >= 2 && tag[0] == "e") { 1133 positional_ids.push(tag[1]) 1134 } else if (tag.length >= 2 && tag[0] == "p") { 1135 pubkeys.push(tag[1]) 1136 } 1137 1138 i++ 1139 } 1140 1141 if (!(root && reply) && positional_ids.length > 0) 1142 return determine_event_refs_positionally(pubkeys, positional_ids) 1143 1144 /* 1145 if (reply && !root) 1146 root = reply 1147 */ 1148 1149 return {root, reply, pubkeys} 1150 } 1151 1152 function* yield_etags(tags) 1153 { 1154 for (const tag of tags) { 1155 if (tag.length >= 2 && tag[0] === "e") 1156 yield tag 1157 } 1158 } 1159 1160 function expand_thread(id, reply_id) { 1161 const view = get_current_view() 1162 const root_id = get_thread_root_id(DAMUS, id) 1163 if (!root_id) { 1164 log_debug("could not get root_id for", DAMUS.all_events[id]) 1165 return 1166 } 1167 1168 view.expanded.add(reply_id) 1169 view.depths[root_id] = get_thread_max_depth(DAMUS, view, root_id) + 1 1170 1171 redraw_events(DAMUS, view) 1172 } 1173 1174 function get_thread_root_id(damus, id) 1175 { 1176 const ev = damus.all_events[id] 1177 if (!ev) { 1178 log_debug("expand_thread: no event found?", id) 1179 return null 1180 } 1181 1182 return ev.refs && ev.refs.root 1183 } 1184 1185 function get_default_max_depth(damus, view) 1186 { 1187 return view.max_depth || damus.max_depth 1188 } 1189 1190 function get_thread_max_depth(damus, view, root_id) 1191 { 1192 if (!view.depths[root_id]) 1193 return get_default_max_depth(damus, view) 1194 1195 return view.depths[root_id] 1196 } 1197 1198 function delete_post_confirm(evid) { 1199 if (!confirm("Are you sure you want to delete this post?")) 1200 return 1201 1202 const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() 1203 1204 if (reason.toLowerCase() === "cancel") 1205 return 1206 1207 delete_post(evid, reason) 1208 } 1209 1210 function shouldnt_render_event(our_pk, view, ev, opts) { 1211 return !opts.is_composing && 1212 !view.expanded.has(ev.id) && 1213 view.rendered.has(ev.id) 1214 } 1215 1216 function press_logout() { 1217 if (confirm("Are you sure you want to sign out?")) { 1218 localStorage.clear(); 1219 const url = new URL(location.href) 1220 url.searchParams.delete("pk") 1221 window.location.href = url.toString() 1222 } 1223 } 1224 1225 async function delete_post(id, reason) 1226 { 1227 const ev = DAMUS.all_events[id] 1228 if (!ev) 1229 return 1230 1231 const pubkey = await get_pubkey() 1232 let del = await create_deletion_event(pubkey, id, reason) 1233 console.log("deleting", ev) 1234 broadcast_event(del) 1235 } 1236 1237 function get_reactions(model, evid) 1238 { 1239 const reactions_set = model.reactions_to[evid] 1240 if (!reactions_set) 1241 return "" 1242 1243 let reactions = [] 1244 for (const id of reactions_set.keys()) { 1245 if (is_deleted(model, id)) 1246 continue 1247 const reaction = model.all_events[id] 1248 if (!reaction) 1249 continue 1250 reactions.push(reaction) 1251 } 1252 1253 const groups = reactions.reduce((grp, r) => { 1254 const e = get_reaction_emoji(r) 1255 grp[e] = grp[e] || {} 1256 grp[e][r.pubkey] = r 1257 return grp 1258 }, {}) 1259 1260 return groups 1261 } 1262 1263 function close_reply() { 1264 const modal = document.querySelector("#reply-modal") 1265 modal.classList.add("closed"); 1266 } 1267 1268 function gather_reply_tags(pubkey, from) { 1269 let tags = [] 1270 let ids = new Set() 1271 1272 if (from.refs && from.refs.root) { 1273 tags.push(["e", from.refs.root, "", "root"]) 1274 ids.add(from.refs.root) 1275 } 1276 1277 tags.push(["e", from.id, "", "reply"]) 1278 ids.add(from.id) 1279 1280 for (const tag of from.tags) { 1281 if (tag.length >= 2) { 1282 if (tag[0] === "p" && tag[1] !== pubkey) { 1283 if (!ids.has(tag[1])) { 1284 tags.push(["p", tag[1]]) 1285 ids.add(tag[1]) 1286 } 1287 } 1288 } 1289 } 1290 if (from.pubkey !== pubkey && !ids.has(from.pubkey)) { 1291 tags.push(["p", from.pubkey]) 1292 } 1293 return tags 1294 } 1295 1296 async function create_deletion_event(pubkey, target, content="") 1297 { 1298 const created_at = Math.floor(new Date().getTime() / 1000) 1299 let kind = 5 1300 1301 const tags = [["e", target]] 1302 let del = { pubkey, tags, content, created_at, kind } 1303 1304 del.id = await nostrjs.calculate_id(del) 1305 del = await sign_event(del) 1306 return del 1307 } 1308 1309 async function create_reply(pubkey, content, from) { 1310 const tags = gather_reply_tags(pubkey, from) 1311 const created_at = Math.floor(new Date().getTime() / 1000) 1312 let kind = from.kind 1313 1314 // convert emoji replies into reactions 1315 if (is_valid_reaction_content(content)) 1316 kind = 7 1317 1318 let reply = { pubkey, tags, content, created_at, kind } 1319 1320 reply.id = await nostrjs.calculate_id(reply) 1321 reply = await sign_event(reply) 1322 return reply 1323 } 1324 1325 function get_tag_event(tag) 1326 { 1327 if (tag.length < 2) 1328 return null 1329 1330 if (tag[0] === "e") 1331 return DAMUS.all_events[tag[1]] 1332 1333 if (tag[0] === "p") 1334 return DAMUS.all_events[DAMUS.profile_events[tag[1]]] 1335 1336 return null 1337 } 1338 1339 async function broadcast_related_events(ev) 1340 { 1341 ev.tags 1342 .reduce((evs, tag) => { 1343 // cap it at something sane 1344 if (evs.length >= 5) 1345 return evs 1346 const ev = get_tag_event(tag) 1347 if (!ev) 1348 return evs 1349 insert_event_sorted(evs, ev) // for uniqueness 1350 return evs 1351 }, []) 1352 .forEach((ev, i) => { 1353 // so we don't get rate limited 1354 setTimeout(() => { 1355 log_debug("broadcasting related event", ev) 1356 broadcast_event(ev) 1357 }, (i+1)*1200) 1358 }) 1359 } 1360 1361 function broadcast_event(ev) { 1362 DAMUS.pool.send(["EVENT", ev]) 1363 } 1364 1365 async function send_reply(content, replying_to) 1366 { 1367 const ev = DAMUS.all_events[replying_to] 1368 if (!ev) 1369 return 1370 1371 const pubkey = await get_pubkey() 1372 let reply = await create_reply(pubkey, content, ev) 1373 1374 broadcast_event(reply) 1375 broadcast_related_events(reply) 1376 } 1377 1378 async function do_send_reply() { 1379 const modal = document.querySelector("#reply-modal") 1380 const replying_to = modal.querySelector("#replying-to") 1381 1382 const evid = replying_to.dataset.evid 1383 const reply_content_el = document.querySelector("#reply-content") 1384 const content = reply_content_el.value 1385 1386 await send_reply(content, evid) 1387 1388 reply_content_el.value = "" 1389 1390 close_reply() 1391 } 1392 1393 function get_local_state(key) { 1394 if (DAMUS[key] != null) 1395 return DAMUS[key] 1396 1397 return localStorage.getItem(key) 1398 } 1399 1400 function set_local_state(key, val) { 1401 DAMUS[key] = val 1402 localStorage.setItem(key, val) 1403 } 1404 1405 function get_qs(loc=location.href) { 1406 return new URL(loc).searchParams 1407 } 1408 1409 async function get_nip05_pubkey(email) { 1410 const [user, host] = email.split("@") 1411 const url = `https://${host}/.well-known/nostr.json?name=${user}` 1412 1413 try { 1414 const res = await fetch(url) 1415 const json = await res.json() 1416 1417 log_debug("nip05 data", json) 1418 return json.names[user] 1419 } catch (e) { 1420 log_error("fetching nip05 entry for %s", email, e) 1421 throw e 1422 } 1423 } 1424 1425 async function handle_pubkey(pubkey) { 1426 if (pubkey[0] === "n") 1427 pubkey = bech32_decode(pubkey) 1428 1429 if (pubkey.includes("@")) 1430 pubkey = await get_nip05_pubkey(pubkey) 1431 1432 set_local_state('pubkey', pubkey) 1433 1434 return pubkey 1435 } 1436 1437 async function get_pubkey() { 1438 let pubkey = get_local_state('pubkey') 1439 1440 // qs pk overrides stored key 1441 const qs_pk = get_qs().get("pk") 1442 if (qs_pk) 1443 return await handle_pubkey(qs_pk) 1444 1445 if (pubkey) 1446 return pubkey 1447 1448 console.log("window.nostr", window.nostr) 1449 if (window.nostr && window.nostr.getPublicKey) { 1450 console.log("calling window.nostr.getPublicKey()...") 1451 const pubkey = await window.nostr.getPublicKey() 1452 console.log("got %s pubkey from nos2x", pubkey) 1453 return await handle_pubkey(pubkey) 1454 } 1455 1456 pubkey = prompt("Enter nostr id (eg: jb55@jb55.com) or pubkey (hex or npub)") 1457 1458 if (!pubkey) 1459 throw new Error("Need pubkey to continue") 1460 1461 return await handle_pubkey(pubkey) 1462 } 1463 1464 function get_privkey() { 1465 let privkey = get_local_state('privkey') 1466 1467 if (privkey) 1468 return privkey 1469 1470 if (!privkey) 1471 privkey = prompt("Enter private key") 1472 1473 if (!privkey) 1474 throw new Error("can't get privkey") 1475 1476 if (privkey[0] === "n") { 1477 privkey = bech32_decode(privkey) 1478 } 1479 1480 set_local_state('privkey', privkey) 1481 1482 return privkey 1483 } 1484 1485 async function sign_id(privkey, id) 1486 { 1487 //const digest = nostrjs.hex_decode(id) 1488 const sig = await nobleSecp256k1.schnorr.sign(id, privkey) 1489 return nostrjs.hex_encode(sig) 1490 } 1491 1492 function reply_to(evid) { 1493 const modal = document.querySelector("#reply-modal") 1494 const replybox = modal.querySelector("#reply-content") 1495 modal.classList.remove("closed") 1496 const replying_to = modal.querySelector("#replying-to") 1497 1498 replying_to.dataset.evid = evid 1499 1500 const ev = DAMUS.all_events[evid] 1501 const view = get_current_view() 1502 replying_to.innerHTML = render_event(DAMUS, view, ev, {is_composing: true, nobar: true, max_depth: 1}) 1503 1504 replybox.focus() 1505 } 1506 1507 function convert_quote_blocks(content, show_media) 1508 { 1509 const split = content.split("\n") 1510 let blockin = false 1511 return split.reduce((str, line) => { 1512 if (line !== "" && line[0] === '>') { 1513 if (!blockin) { 1514 str += "<span class='quote'>" 1515 blockin = true 1516 } 1517 str += linkify(sanitize(line.slice(1)), show_media) 1518 } else { 1519 if (blockin) { 1520 blockin = false 1521 str += "</span>" 1522 } 1523 str += linkify(sanitize(line), show_media) 1524 } 1525 return str + "<br/>" 1526 }, "") 1527 } 1528 1529 function get_content_warning(tags) 1530 { 1531 for (const tag of tags) { 1532 if (tag.length >= 1 && tag[0] === "content-warning") 1533 return sanitize(tag[1]) || "" 1534 } 1535 1536 return null 1537 } 1538 1539 function toggle_content_warning(el) 1540 { 1541 const id = el.id.split("_")[1] 1542 const ev = DAMUS.all_events[id] 1543 1544 if (!ev) { 1545 log_debug("could not find content-warning event", id) 1546 return 1547 } 1548 1549 DAMUS.cw_open[id] = el.open 1550 } 1551 1552 function format_content(ev, show_media) 1553 { 1554 if (ev.kind === 7) { 1555 if (ev.content === "" || ev.content === "+") 1556 return "❤️" 1557 return sanitize(ev.content.trim()) 1558 } 1559 1560 const content = ev.content.trim() 1561 const body = convert_quote_blocks(content, show_media) 1562 1563 let cw = get_content_warning(ev.tags) 1564 if (cw !== null) { 1565 let cwHTML = "Content Warning" 1566 if (cw === "") { 1567 cwHTML += "." 1568 } else { 1569 cwHTML += `: "<span>${cw}</span>".` 1570 } 1571 const open = !!DAMUS.cw_open[ev.id]? "open" : "" 1572 return ` 1573 <details ontoggle="toggle_content_warning(this)" class="cw" id="cw_${ev.id}" ${open}> 1574 <summary class="event-message">${cwHTML}</summary> 1575 ${body} 1576 </details> 1577 ` 1578 } 1579 1580 return body 1581 } 1582 1583 function sanitize(content) 1584 { 1585 if (!content) 1586 return "" 1587 return DOMPurify.sanitize(content) 1588 } 1589 1590 function robohash(pk) { 1591 return "https://robohash.org/" + pk 1592 } 1593 1594 function get_picture(pk, profile) { 1595 if (!profile) 1596 return robohash(pk) 1597 if (profile.resolved_picture) 1598 return profile.resolved_picture 1599 profile.resolved_picture = profile.picture || robohash(pk) 1600 return profile.resolved_picture 1601 } 1602 1603 function passes_spam_filter(contacts, ev, pow) 1604 { 1605 if (contacts.friend_of_friends.has(ev.pubkey)) 1606 return true 1607 1608 return ev.pow >= pow 1609 } 1610