let DAMUS const BOOTSTRAP_RELAYS = [ "wss://relay.damus.io", "wss://nostr-relay.wlvs.space", "wss://nostr-pub.wellorder.net" ] function uuidv4() { return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) ); } function insert_event_sorted(evs, new_ev) { for (let i = 0; i < evs.length; i++) { const ev = evs[i] if (new_ev.id === ev.id) { return false } if (new_ev.created_at > ev.created_at) { evs.splice(i, 0, new_ev) return true } } evs.push(new_ev) return true } function init_contacts() { return { event: null, friends: new Set(), friend_of_friends: new Set(), } } function init_home_model() { return { done_init: false, loading: true, notifications: 0, rendered: {}, all_events: {}, expanded: new Set(), reactions_to: {}, events: [], chatrooms: {}, deletions: {}, cw_open: {}, deleted: {}, profiles: {}, profile_events: {}, last_event_of_kind: {}, contacts: init_contacts() } } function update_favicon(path) { let link = document.querySelector("link[rel~='icon']"); const head = document.getElementsByTagName('head')[0] if (!link) { link = document.createElement('link'); link.rel = 'icon'; head.appendChild(link); } link.href = path; } function update_title(model) { if (document.visibilityState === 'visible') model.notifications = 0 if (model.notifications === 0) { document.title = "Damus" update_favicon("img/damus.svg") } else { document.title = `(${model.notifications}) Damus` update_favicon("img/damus_notif.svg") } } function notice_chatroom(state, id) { if (!state.chatrooms[id]) state.chatrooms[id] = {} } async function damus_web_init() { const model = init_home_model() DAMUS = model model.pubkey = await get_pubkey() if (!model.pubkey) return const {RelayPool} = nostrjs const pool = RelayPool(BOOTSTRAP_RELAYS) const now = (new Date().getTime()) / 1000 const ids = { comments: "comments",//uuidv4(), profiles: "profiles",//uuidv4(), refevents: "refevents",//uuidv4(), account: "account",//uuidv4(), home: "home",//uuidv4(), contacts: "contacts",//uuidv4(), notifications: "notifications",//uuidv4(), dms: "dms",//uuidv4(), } model.pool = pool model.view_el = document.querySelector("#view") redraw_home_view(model) document.addEventListener('visibilitychange', () => { update_title(model) }) pool.on('open', (relay) => { //let authors = followers // TODO: fetch contact list log_debug("relay connected", relay.url) if (!model.done_init) { model.loading = false send_initial_filters(ids.account, model.pubkey, relay) } else { send_home_filters(ids, model, relay) } //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) }); pool.on('event', (relay, sub_id, ev) => { handle_home_event(ids, model, relay, sub_id, ev) }) pool.on('eose', async (relay, sub_id) => { if (sub_id === ids.home) { handle_comments_loaded(ids.profiles, model, relay) } else if (sub_id === ids.profiles) { handle_profiles_loaded(ids.profiles, model, relay) } }) return pool } function process_reaction_event(model, ev) { if (!is_valid_reaction_content(ev.content)) return let last = {} for (const tag of ev.tags) { if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) last[tag[0]] = tag[1] } if (last.e) { model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() model.reactions_to[last.e].add(ev.id) } } function process_chatroom_event(model, ev) { try { model.chatrooms[ev.id] = JSON.parse(ev.content) } catch (err) { log_debug("error processing chatroom creation event", ev, err) } } function process_json_content(ev) { try { ev.json_content = JSON.parse(ev.content) } catch(e) { log_debug("error parsing json content for", ev) } } function process_deletion_event(model, ev) { for (const tag of ev.tags) { if (tag.length >= 2 && tag[0] === "e") { const evid = tag[1] // we've already recorded this one as a valid deleted event // we can just ignore it if (model.deleted[evid]) continue let ds = model.deletions[evid] = (model.deletions[evid] || new Set()) // add the deletion event id to the deletion set of this event // we will use this to determine if this event is valid later in // case we don't have the deleted event yet. ds.add(ev.id) } } } function is_deleted(model, evid) { // we've already know it's deleted if (model.deleted[evid]) return model.deleted[evid] const ev = model.all_events[evid] if (!ev) return false // all deletion events const ds = model.deletions[ev.id] if (!ds) return false // find valid deletion events for (const id of ds.keys()) { const d_ev = model.all_events[id] if (!d_ev) continue // only allow deletes from the user who created it if (d_ev.pubkey === ev.pubkey) { model.deleted[ev.id] = d_ev log_debug("received deletion for", ev) // clean up deletion data that we don't need anymore delete model.deletions[ev.id] return true } else { log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) } } return false } function process_event(model, ev) { ev.refs = determine_event_refs(ev.tags) const notified = was_pubkey_notified(model.pubkey, ev) ev.notified = notified if (ev.kind === 7) process_reaction_event(model, ev) else if (ev.kind === 42 && ev.refs && ev.refs.root) notice_chatroom(model, ev.refs.root) else if (ev.kind === 40) process_chatroom_event(model, ev) else if (ev.kind === 6) process_json_content(ev) else if (ev.kind === 5) process_deletion_event(model, ev) else if (ev.kind === 0) process_profile_event(model, ev) const last_notified = get_local_state('last_notified_date') if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { set_local_state('last_notified_date', new Date().getTime()) model.notifications++ update_title(model) } } function was_pubkey_notified(pubkey, ev) { if (!(ev.kind === 1 || ev.kind === 42)) return false if (ev.pubkey === pubkey) return false for (const tag of ev.tags) { if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) return true } return false } function should_add_to_home(ev) { return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 } let rerender_home_timer function handle_home_event(ids, model, relay, sub_id, ev) { // ignore duplicates if (model.all_events[ev.id]) return model.all_events[ev.id] = ev process_event(model, ev) switch (sub_id) { case ids.home: if (should_add_to_home(ev)) insert_event_sorted(model.events, ev) if (model.realtime) { if (rerender_home_timer) clearTimeout(rerender_home_timer) rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) } break; case ids.account: switch (ev.kind) { case 3: model.loading = false process_contact_event(model, ev) model.done_init = true model.pool.unsubscribe(ids.account, [relay]) break } case ids.profiles: break } } function process_profile_event(model, ev) { const prev_ev = model.profile_events[ev.pubkey] if (prev_ev && prev_ev.created_at > ev.created_at) return model.profile_events[ev.pubkey] = ev try { model.profiles[ev.pubkey] = JSON.parse(ev.content) } catch(e) { log_debug("failed to parse profile contents", ev) } } function send_initial_filters(account_id, pubkey, relay) { const filter = {authors: [pubkey], kinds: [3], limit: 1} //console.log("sending initial filter", filter) relay.subscribe(account_id, filter) } function send_home_filters(ids, model, relay) { const friends = contacts_friend_list(model.contacts) friends.push(model.pubkey) const contacts_filter = {kinds: [0], authors: friends} const dms_filter = {kinds: [4], limit: 500} const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} let home_filters = [home_filter] let notifications_filters = [notifications_filter] let contacts_filters = [contacts_filter] let dms_filters = [dms_filter, our_dms_filter] let last_of_kind = {} if (relay) { last_of_kind = model.last_event_of_kind[relay] = model.last_event_of_kind[relay] || {} } update_filters_with_since(last_of_kind, home_filters) update_filters_with_since(last_of_kind, contacts_filters) update_filters_with_since(last_of_kind, notifications_filters) update_filters_with_since(last_of_kind, dms_filters) const subto = relay? [relay] : undefined model.pool.subscribe(ids.home, home_filters, subto) model.pool.subscribe(ids.contacts, contacts_filters, subto) model.pool.subscribe(ids.notifications, notifications_filters, subto) model.pool.subscribe(ids.dms, dms_filters, subto) } function get_since_time(last_event) { if (!last_event) { return null } return last_event.created_at - 60 * 10 } function update_filter_with_since(last_of_kind, filter) { const kinds = filter.kinds || [] let initial = null let earliest = kinds.reduce((earliest, kind) => { const last = last_of_kind[kind] let since = get_since_time(last) if (!earliest) { if (since === null) return null return since } if (since === null) return earliest return since < earliest ? since : earliest }, initial) if (earliest) filter.since = earliest } function update_filters_with_since(last_of_kind, filters) { for (const filter of filters) { update_filter_with_since(last_of_kind, filter) } } function contacts_friend_list(contacts) { return Array.from(contacts.friends) } function process_contact_event(model, ev) { load_our_contacts(model.contacts, model.pubkey, ev) load_our_relays(model.pubkey, model.pool, ev) add_contact_if_friend(model.contacts, ev) } function add_contact_if_friend(contacts, ev) { if (!contact_is_friend(contacts, ev.pubkey)) { return } add_friend_contact(contacts, ev) } function contact_is_friend(contacts, pk) { return contacts.friends.has(pk) } function add_friend_contact(contacts, contact) { contacts.friends[contact.pubkey] = true for (const tag of contact.tags) { if (tag.count >= 2 && tag[0] == "p") { contacts.friend_of_friends.add(tag[1]) } } } function load_our_relays(our_pubkey, pool, ev) { if (ev.pubkey != our_pubkey) return let relays try { relays = JSON.parse(ev.content) } catch (e) { log_debug("error loading relays", e) return } for (const relay of Object.keys(relays)) { log_debug("adding relay", relay) if (!pool.has(relay)) pool.add(relay) } } function log_debug(fmt, ...args) { console.log("[debug] " + fmt, ...args) } function load_our_contacts(contacts, our_pubkey, ev) { if (ev.pubkey !== our_pubkey) return contacts.event = ev for (const tag of ev.tags) { if (tag.length > 1 && tag[0] === "p") { contacts.friends.add(tag[1]) } } } function get_referenced_events(model) { let evset = new Set() for (const ev of model.events) { for (const tag of ev.tags) { if (tag.count >= 2 && tag[0] === "e") { const e = tag[1] if (!model.all_events[e]) { evset.add(e) } } } } return Array.from(evset) } function fetch_referenced_events(refevents_id, model, relay) { const ref = df model.pool.subscribe(refevents_id, [filter], relay) } function handle_profiles_loaded(profiles_id, model, relay) { // stop asking for profiles model.pool.unsubscribe(profiles_id, relay) model.realtime = true redraw_events(model) } function debounce(f, interval) { let timer = null; let first = true; return (...args) => { clearTimeout(timer); return new Promise((resolve) => { timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); first = false }); }; } function get_unknown_chatroom_ids(state) { let chatroom_ids = [] for (const key of Object.keys(state.chatrooms)) { const chatroom = state.chatrooms[key] if (chatroom.name === undefined) chatroom_ids.push(key) } return chatroom_ids } // load profiles after comment notes are loaded function handle_comments_loaded(profiles_id, model, relay) { const pubkeys = model.events.reduce((s, ev) => { s.add(ev.pubkey) return s }, new Set()) const authors = Array.from(pubkeys) // load profiles and noticed chatrooms const chatroom_ids = get_unknown_chatroom_ids(model) const profile_filter = {kinds: [0], authors: authors} const chatroom_filter = {kinds: [40], ids: chatroom_ids} let filters = [profile_filter, chatroom_filter] const ref_evids = get_referenced_events(model) if (ref_evids.length > 0) { log_debug("got %d new referenced events to pull after initial load", ref_evids.length) filters.push({ids: ref_evids}) filters.push({"#e": ref_evids}) } //console.log("subscribe", profiles_id, filter, relay) model.pool.subscribe(profiles_id, filters, relay) } function redraw_events(model) { //log_debug("rendering home view") model.rendered = {} model.events_el.innerHTML = render_events(model) setup_home_event_handlers(model.events_el) } function setup_home_event_handlers(events_el) { for (const el of events_el.querySelectorAll(".cw")) el.addEventListener("toggle", toggle_content_warning.bind(null)) } function redraw_home_view(model) { model.view_el.innerHTML = render_home_view(model) model.events_el = document.querySelector("#events") if (model.events.length > 0) { redraw_events(model) } else { model.events_el.innerHTML= `
` } } async function send_post() { const input_el = document.querySelector("#post-input") const cw_el = document.querySelector("#content-warning-input") const cw = cw_el.value const content = input_el.value const created_at = Math.floor(new Date().getTime() / 1000) const kind = 1 const tags = cw ? [["content-warning", cw]] : [] const pubkey = await get_pubkey() const {pool} = DAMUS let post = { pubkey, tags, content, created_at, kind } post.id = await nostrjs.calculate_id(post) post = await sign_event(post) pool.send(["EVENT", post]) input_el.value = "" cw_el.value = "" post_input_changed(input_el) } async function sign_event(ev) { if (window.nostr && window.nostr.signEvent) { const signed = await window.nostr.signEvent(ev) if (typeof signed === 'string') { ev.sig = signed return ev } return signed } const privkey = get_privkey() ev.sig = await sign_id(privkey, ev.id) return ev } function post_input_changed(el) { document.querySelector("#post-button").disabled = el.value === "" } function determine_event_refs_positionally(pubkeys, ids) { if (ids.length === 1) return {root: ids[0], reply: ids[0], pubkeys} else if (ids.length >= 2) return {root: ids[0], reply: ids[1], pubkeys} return {pubkeys} } function determine_event_refs(tags) { let positional_ids = [] let pubkeys = [] let root let reply let i = 0 for (const tag of tags) { if (tag.length >= 4 && tag[0] == "e") { if (tag[3] === "root") root = tag[1] else if (tag[3] === "reply") reply = tag[1] } else if (tag.length >= 2 && tag[0] == "e") { positional_ids.push(tag[1]) } else if (tag.length >= 2 && tag[0] == "p") { pubkeys.push(tag[1]) } i++ } if (!root && !reply && positional_ids.length > 0) return determine_event_refs_positionally(pubkeys, positional_ids) return {root, reply, pubkeys} } function can_reply(ev) { return ev.kind === 1 || ev.kind === 42 } const DEFAULT_PROFILE = { name: "anon", display_name: "Anonymous", } function* yield_etags(tags) { for (const tag of tags) { if (tag.length >= 2 && tag[0] === "e") yield tag } } function expand_thread(id) { const ev = DAMUS.all_events[id] if (ev) { for (const tag of yield_etags(ev.tags)) DAMUS.expanded.add(tag[1]) } else { log_debug("expand_thread, id not found?", id) } DAMUS.expanded.add(id) redraw_events(DAMUS) } function delete_post_confirm(evid) { if (!confirm("Are you sure you want to delete this post?")) return const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() if (reason.toLowerCase() === "cancel") return delete_post(evid, reason) } function shouldnt_render_event(model, ev, opts) { return !opts.is_boost_event && !opts.is_composing && !model.expanded.has(ev.id) && model.rendered[ev.id] } function press_logout() { if (confirm("Are you sure you want to sign out?")) { localStorage.clear(); const url = new URL(location.href) url.searchParams.delete("pk") window.location.href = url.toString() } } 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])?))?)$/ const WORD_REGEX=/\w/ function is_emoji(str) { return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) } function is_valid_reaction_content(content) { return content === "+" || content === "" || is_emoji(content) } function get_reaction_emoji(ev) { if (ev.content === "+" || ev.content === "") return "❤️" return ev.content } async function delete_post(id, reason) { const ev = DAMUS.all_events[id] if (!ev) return const pubkey = await get_pubkey() let del = await create_deletion_event(pubkey, id, reason) console.log("deleting", ev) broadcast_event(del) } function get_reactions(model, evid) { const reactions_set = model.reactions_to[evid] if (!reactions_set) return "" let reactions = [] for (const id of reactions_set.keys()) { if (is_deleted(model, id)) continue const reaction = model.all_events[id] if (!reaction) continue reactions.push(reaction) } const groups = reactions.reduce((grp, r) => { const e = get_reaction_emoji(r) grp[e] = grp[e] || {} grp[e][r.pubkey] = r return grp }, {}) return groups } function close_reply() { const modal = document.querySelector("#reply-modal") modal.classList.add("closed"); } function gather_reply_tags(pubkey, from) { let tags = [] for (const tag of from.tags) { if (tag.length >= 2) { if (tag[0] === "e") { tags.push(tag) } else if (tag[0] === "p" && tag[1] !== pubkey) { tags.push(tag) } } } tags.push(["e", from.id, "", "reply"]) if (from.pubkey !== pubkey) tags.push(["p", from.pubkey]) return tags } async function create_deletion_event(pubkey, target, content="") { const created_at = Math.floor(new Date().getTime() / 1000) let kind = 5 const tags = [["e", target]] let del = { pubkey, tags, content, created_at, kind } del.id = await nostrjs.calculate_id(del) del = await sign_event(del) return del } async function create_reply(pubkey, content, from) { const tags = gather_reply_tags(pubkey, from) const created_at = Math.floor(new Date().getTime() / 1000) let kind = from.kind // convert emoji replies into reactions if (is_valid_reaction_content(content)) kind = 7 let reply = { pubkey, tags, content, created_at, kind } reply.id = await nostrjs.calculate_id(reply) reply = await sign_event(reply) return reply } function get_tag_event(tag) { if (tag.length < 2) return null if (tag[0] === "e") return DAMUS.all_events[tag[1]] if (tag[0] === "p") return DAMUS.profile_events[tag[1]] return null } async function broadcast_related_events(ev) { ev.tags .reduce((evs, tag) => { // cap it at something sane if (evs.length >= 5) return evs const ev = get_tag_event(tag) if (!ev) return evs insert_event_sorted(evs, ev) // for uniqueness return evs }, []) .forEach((ev, i) => { // so we don't get rate limited setTimeout(() => { log_debug("broadcasting related event", ev) broadcast_event(ev) }, (i+1)*1200) }) } function broadcast_event(ev) { DAMUS.pool.send(["EVENT", ev]) } async function send_reply(content, replying_to) { const ev = DAMUS.all_events[replying_to] if (!ev) return const pubkey = await get_pubkey() let reply = await create_reply(pubkey, content, ev) broadcast_event(reply) broadcast_related_events(reply) } async function do_send_reply() { const modal = document.querySelector("#reply-modal") const replying_to = modal.querySelector("#replying-to") const evid = replying_to.dataset.evid const reply_content_el = document.querySelector("#reply-content") const content = reply_content_el.value await send_reply(content, evid) reply_content_el.value = "" close_reply() } function bech32_decode(pubkey) { const decoded = bech32.decode(pubkey) const bytes = fromWords(decoded.words) return nostrjs.hex_encode(bytes) } function get_local_state(key) { if (DAMUS[key] != null) return DAMUS[key] return localStorage.getItem(key) } function set_local_state(key, val) { DAMUS[key] = val localStorage.setItem(key, val) } function get_qs(loc=location.href) { return new URL(loc).searchParams } function handle_pubkey(pubkey) { if (pubkey[0] === "n") pubkey = bech32_decode(pubkey) set_local_state('pubkey', pubkey) return pubkey } async function get_pubkey() { let pubkey = get_local_state('pubkey') // qs pk overrides stored key const qs_pk = get_qs().get("pk") if (qs_pk) return handle_pubkey(qs_pk) if (pubkey) return pubkey if (window.nostr && window.nostr.getPublicKey) { const pubkey = await window.nostr.getPublicKey() console.log("got %s pubkey from nos2x", pubkey) return pubkey } pubkey = prompt("Enter pubkey (hex or npub)") if (!pubkey) throw new Error("Need pubkey to continue") return handle_pubkey(pubkey) } function get_privkey() { let privkey = get_local_state('privkey') if (privkey) return privkey if (!privkey) privkey = prompt("Enter private key") if (!privkey) throw new Error("can't get privkey") if (privkey[0] === "n") { privkey = bech32_decode(privkey) } set_local_state('privkey', privkey) return privkey } async function sign_id(privkey, id) { //const digest = nostrjs.hex_decode(id) const sig = await nobleSecp256k1.schnorr.sign(id, privkey) return nostrjs.hex_encode(sig) } function reply_to(evid) { const modal = document.querySelector("#reply-modal") const replybox = modal.querySelector("#reply-content") modal.classList.remove("closed") const replying_to = modal.querySelector("#replying-to") replying_to.dataset.evid = evid const ev = DAMUS.all_events[evid] replying_to.innerHTML = render_event(DAMUS, ev, {is_composing: true, nobar: true, max_depth: 1}) replybox.focus() } const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i function is_img_url(path) { return IMG_REGEX.test(path) } const VID_REGEX = /(webm|mp4)$/i function is_video_url(path) { return VID_REGEX.test(path) } const URL_REGEX = /(https?:\/\/[^\s]+)[,:)]?(\w|$)/g; function linkify(text, show_media) { return text.replace(URL_REGEX, function(url) { const parsed = new URL(url) if (show_media && is_img_url(parsed.pathname)) return ` `; else if (show_media && is_video_url(parsed.pathname)) return ` `; else return `${url}`; }) } function convert_quote_blocks(content, show_media) { const split = content.split("\n") let blockin = false return split.reduce((str, line) => { if (line !== "" && line[0] === '>') { if (!blockin) { str += "" blockin = true } str += linkify(sanitize(line.slice(1)), show_media) } else { if (blockin) { blockin = false str += "" } str += linkify(sanitize(line), show_media) } return str + "
" }, "") } function get_content_warning(tags) { for (const tag of tags) { if (tag.length >= 1 && tag[0] === "content-warning") return tag[1] || "" } return null } function toggle_content_warning(e) { const el = e.target const id = el.id.split("_")[1] const ev = DAMUS.all_events[id] if (!ev) { log_debug("could not find content-warning event", id) return } DAMUS.cw_open[id] = el.open } function format_content(ev, show_media) { if (ev.kind === 7) { if (ev.content === "" || ev.content === "+") return "❤️" return sanitize(ev.content.trim()) } const content = ev.content.trim() const body = convert_quote_blocks(content, show_media) let cw = get_content_warning(ev.tags) if (cw !== null) { let cwHTML = "Content Warning" if (cw === "") { cwHTML += "." } else { cwHTML += `: "${cw}".` } const open = !!DAMUS.cw_open[ev.id]? "open" : "" return `
${cwHTML} ${body}
` } return body } function sanitize(content) { if (!content) return "" return content.replaceAll("<","<").replaceAll(">",">") } function robohash(pk) { return "https://robohash.org/" + pk } function get_picture(pk, profile) { if (profile.resolved_picture) return profile.resolved_picture profile.resolved_picture = sanitize(profile.picture) || robohash(pk) return profile.resolved_picture } function time_delta(current, previous) { var msPerMinute = 60 * 1000; var msPerHour = msPerMinute * 60; var msPerDay = msPerHour * 24; var msPerMonth = msPerDay * 30; var msPerYear = msPerDay * 365; var elapsed = current - previous; if (elapsed < msPerMinute) { return Math.round(elapsed/1000) + ' seconds ago'; } else if (elapsed < msPerHour) { return Math.round(elapsed/msPerMinute) + ' minutes ago'; } else if (elapsed < msPerDay ) { return Math.round(elapsed/msPerHour ) + ' hours ago'; } else if (elapsed < msPerMonth) { return Math.round(elapsed/msPerDay) + ' days ago'; } else if (elapsed < msPerYear) { return Math.round(elapsed/msPerMonth) + ' months ago'; } else { return Math.round(elapsed/msPerYear ) + ' years ago'; } }