diff --git a/web/index.html b/web/index.html index 9539f73..6488152 100644 --- a/web/index.html +++ b/web/index.html @@ -11,9 +11,12 @@ + + + diff --git a/web/js/core.js b/web/js/core.js new file mode 100644 index 0000000..1bc58c3 --- /dev/null +++ b/web/js/core.js @@ -0,0 +1,253 @@ +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) +} + +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) +} + +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 update_profile() { + const kind = 0 + const created_at = new_creation_time() + const pubkey = await get_pubkey() + const content = JSON.stringify({ + name: "test", + about: "Testing", + picture: "", + nip05: "" + }) + + let ev = { pubkey, content, created_at, kind } + ev.id = await nostrjs.calculate_id(ev) + ev = await sign_event(ev) + broadcast_event(ev) + // TODO add error checking on updating profile +}*/ + +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 +} + +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 = new_creation_time() + const kind = 1 + const tags = cw ? [["content-warning", cw]] : [] + const pubkey = await get_pubkey() + + let post = { pubkey, tags, content, created_at, kind } + post.id = await nostrjs.calculate_id(post) + post = await sign_event(post) + broadcast_event(post) + + input_el.value = "" + cw_el.value = "" + post_input_changed(input_el) +} + + +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 +} + +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 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 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) + 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 gather_reply_tags(pubkey, from) { + let tags = [] + let ids = new Set() + + if (from.refs && from.refs.root) { + tags.push(["e", from.refs.root, "", "root"]) + ids.add(from.refs.root) + } + + tags.push(["e", from.id, "", "reply"]) + ids.add(from.id) + + for (const tag of from.tags) { + if (tag.length >= 2) { + if (tag[0] === "p" && tag[1] !== pubkey) { + if (!ids.has(tag[1])) { + tags.push(["p", tag[1]]) + ids.add(tag[1]) + } + } + } + } + if (from.pubkey !== pubkey && !ids.has(from.pubkey)) { + tags.push(["p", from.pubkey]) + } + return tags +} + +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.all_events[DAMUS.profile_events[tag[1]]] + + return null +} + +function* yield_etags(tags) { + for (const tag of tags) { + if (tag.length >= 2 && tag[0] === "e") + yield tag + } +} + +function get_content_warning(tags) { + for (const tag of tags) { + if (tag.length >= 1 && tag[0] === "content-warning") + return tag[1] || "" + } + return null +} + +async function get_nip05_pubkey(email) { + const [user, host] = email.split("@") + const url = `https://${host}/.well-known/nostr.json?name=${user}` + + try { + const res = await fetch(url) + const json = await res.json() + log_debug("nip05 data", json) + return json.names[user] + } catch (e) { + log_error("fetching nip05 entry for %s", email, e) + throw e + } +} + +// TODO rename handle_pubkey to fetch_pubkey +async function handle_pubkey(pubkey) { + if (pubkey[0] === "n") + pubkey = bech32_decode(pubkey) + if (pubkey.includes("@")) + pubkey = await get_nip05_pubkey(pubkey) + set_local_state('pubkey', pubkey) + return pubkey +} + diff --git a/web/js/damus.js b/web/js/damus.js index aafb74a..33243b5 100644 --- a/web/js/damus.js +++ b/web/js/damus.js @@ -1,4 +1,3 @@ - let DAMUS const BOOTSTRAP_RELAYS = [ @@ -12,22 +11,102 @@ const DEFAULT_PROFILE = { display_name: "Anonymous", } +async function damus_web_init() { + init_message_textareas(); + let tries = 0; + function init() { + // only wait for 500ms max + const max_wait = 500 + const interval = 20 + if (window.nostr || tries >= (max_wait/interval)) { + console.info("init after", tries); + damus_web_init_ready(); + return; + } + tries++; + setTimeout(init, interval); + } + init(); +} + +async function damus_web_init_ready() { + 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_creation_time() + const ids = { + comments: "comments",//uuidv4(), + profiles: "profiles",//uuidv4(), + explore: "explore",//uuidv4(), + refevents: "refevents",//uuidv4(), + account: "account",//uuidv4(), + home: "home",//uuidv4(), + contacts: "contacts",//uuidv4(), + notifications: "notifications",//uuidv4(), + unknowns: "unknowns",//uuidv4(), + dms: "dms",//uuidv4(), + } + + model.ids = ids + model.pool = pool + load_cache(model) + model.view_el = document.querySelector("#view") + + switch_view('home') + 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[relay]) { + send_initial_filters(ids.account, model.pubkey, relay) + } else { + send_home_filters(model, relay) + } + //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) + }); + pool.on('event', (relay, sub_id, ev) => { + handle_home_event(model, relay, sub_id, ev) + }) + pool.on('notice', (relay, notice) => { + log_debug("NOTICE", relay, notice) + }) + pool.on('eose', async (relay, sub_id) => { + if (sub_id === ids.home) { + //log_debug("got home EOSE from %s", relay.url) + const events = model.views.home.events + handle_comments_loaded(ids, model, events, relay) + } else if (sub_id === ids.profiles) { + //log_debug("got profiles EOSE from %s", relay.url) + const view = get_current_view() + handle_profiles_loaded(ids, model, view, relay) + } else if (sub_id === ids.unknowns) { + model.pool.unsubscribe(ids.unknowns, relay) + } + }) + return pool +} + 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 + 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() { @@ -83,187 +162,50 @@ function init_home_model() { } } -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; -} - -// update_title updates the document title & visual indicators based on if the -// number of notifications that are unseen by the user. -function update_title(model) { - // TODO rename update_title to update_notification_state or similar - // TODO only clear notifications once they have seen all targeted events - if (document.visibilityState === 'visible') { - model.notifications = 0 - } - - const num = model.notifications - const has_notes = num !== 0 - document.title = has_notes ? `(${num}) Damus` : "Damus"; - update_favicon(has_notes ? "img/damus_notif.svg" : "img/damus.svg"); - update_notification_markers(has_notes) -} - -function notice_chatroom(state, id) -{ +function notice_chatroom(state, id) { if (!state.chatrooms[id]) state.chatrooms[id] = {} } -async function damus_web_init() -{ - init_message_textareas(); - - let tries = 0; - function init() { - // only wait for 500ms max - const max_wait = 500 - const interval = 20 - if (window.nostr || tries >= (max_wait/interval)) { - console.info("init after", tries); - damus_web_init_ready(); - return; - } - tries++; - setTimeout(init, interval); - } - init(); -} - -async function damus_web_init_ready() -{ - 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(), - explore: "explore",//uuidv4(), - refevents: "refevents",//uuidv4(), - account: "account",//uuidv4(), - home: "home",//uuidv4(), - contacts: "contacts",//uuidv4(), - notifications: "notifications",//uuidv4(), - unknowns: "unknowns",//uuidv4(), - dms: "dms",//uuidv4(), - } - - model.ids = ids - - model.pool = pool - - load_cache(model) - model.view_el = document.querySelector("#view") - - switch_view('home') - - 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[relay]) { - send_initial_filters(ids.account, model.pubkey, relay) - } else { - send_home_filters(model, relay) - } - //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) - }); - - pool.on('event', (relay, sub_id, ev) => { - handle_home_event(model, relay, sub_id, ev) - }) - - pool.on('notice', (relay, notice) => { - log_debug("NOTICE", relay, notice) - }) - - pool.on('eose', async (relay, sub_id) => { - if (sub_id === ids.home) { - //log_debug("got home EOSE from %s", relay.url) - const events = model.views.home.events - handle_comments_loaded(ids, model, events, relay) - } else if (sub_id === ids.profiles) { - //log_debug("got profiles EOSE from %s", relay.url) - const view = get_current_view() - handle_profiles_loaded(ids, model, view, relay) - } else if (sub_id === ids.unknowns) { - model.pool.unsubscribe(ids.unknowns, relay) - } - }) - - return pool -} - -function process_reaction_event(model, ev) -{ +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_chatroom_event(model, ev) { + model.chatrooms[ev.id] = safe_parse_json(ev.content, + "chatroom create event"); } -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_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 process_deletion_event(model, ev) -{ +function process_json_content(ev) { + ev.json_content = safe_parse_json(ev.content, "event json_content"); +} + +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 @@ -273,8 +215,8 @@ function process_deletion_event(model, ev) } } -function is_deleted(model, evid) -{ +// TODO rename is_deleted to is_event_deleted +function is_deleted(model, evid) { // we've already know it's deleted if (model.deleted[evid]) return model.deleted[evid] @@ -305,7 +247,6 @@ function is_deleted(model, evid) log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) } } - return false } @@ -317,25 +258,15 @@ function has_event(damus, evid) { return evid in damus.all_events } -const ID_REG = /^[a-f0-9]{64}$/ -function is_valid_id(evid) -{ - return ID_REG.test(evid) -} - -function make_unk(hint, ev) -{ +function make_unk(hint, ev) { const attempts = 0 const parent_created = ev.created_at - if (hint && hint !== "") return {attempts, hint: hint.trim().toLowerCase(), parent_created} - return {attempts, parent_created} } -function notice_unknown_ids(damus, ev) -{ +function notice_unknown_ids(damus, ev) { // make sure this event itself is removed from unknowns if (ev.kind === 0) delete damus.unknown_pks[ev.pubkey] @@ -367,19 +298,16 @@ function notice_unknown_ids(damus, ev) function gather_unknown_hints(damus, pks, evids) { let relays = new Set() - for (const pk of pks) { const unk = damus.unknown_pks[pk] if (unk && unk.hint && unk.hint !== "") relays.add(unk.hint) } - for (const evid of evids) { const unk = damus.unknown_ids[evid] if (unk && unk.hint && unk.hint !== "") relays.add(unk.hint) } - return Array.from(relays) } @@ -425,43 +353,26 @@ function fetch_unknown_events(damus) const pks = get_non_expired_unknowns(damus.unknown_pks, 'profiles') const evids = get_non_expired_unknowns(damus.unknown_ids, 'events') - const relays = gather_unknown_hints(damus, pks, evids) - for (const relay of relays) { if (!damus.pool.has(relay)) { log_debug("adding %s to relays to fetch unknown events", relay) damus.pool.add(relay) } } - if (evids.length !== 0) { const unk_kinds = [1,5,6,7,40,42] filters.push({ids: evids, kinds: unk_kinds}) filters.push({"#e": evids, kinds: [1,42], limit: 100}) } - if (pks.length !== 0) filters.push({authors: pks, kinds:[0]}) - if (filters.length === 0) return - log_debug("fetching unknowns", filters) damus.pool.subscribe('unknowns', filters) } -function shuffle(arr) -{ - let i = arr.length; - while (--i > 0) { - let randIndex = Math.floor(Math.random() * (i + 1)); - [arr[randIndex], arr[i]] = [arr[i], arr[randIndex]]; - } - return arr; -} - - function schedule_unknown_refetch(damus) { const INTERVAL = 5000 @@ -561,16 +472,6 @@ function should_add_to_explore_timeline(contacts, view, ev, pow) return passes_spam_filter(contacts, ev, pow) } -function get_current_view() -{ - // TODO resolve memory & html descriptencies - // Currently there is tracking of which divs are visible in HTML/CSS and - // which is active in memory, simply resolve this by finding the visible - // element instead of tracking it in memory (or remove dom elements). This - // would simplify state tracking IMO - Thomas - return DAMUS.views[DAMUS.current_view] -} - function handle_redraw_logic(model, view_name) { const view = model.views[view_name] @@ -586,21 +487,7 @@ function schedule_save_events(damus) damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000) } -function is_valid_time(now_sec, created_at) -{ - // don't count events far in the future - if (created_at - now_sec >= 120) { - return false - } - return true -} - -function max(a, b) { - return a > b ? a : b -} - -function calculate_last_of_kind(evs) -{ +function calculate_last_of_kind(evs) { const now_sec = new Date().getTime() / 1000 return Object.keys(evs).reduce((obj, evid) => { const ev = evs[evid] @@ -612,8 +499,38 @@ function calculate_last_of_kind(evs) }, {}) } -function load_events(damus) -{ +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_error("error loading relays", e) + return + } + + for (const relay of Object.keys(relays)) { + if (!pool.has(relay)) { + log_debug("adding relay", relay) + pool.add(relay) + } + } +} + +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 load_events(damus) { if (!('event_cache' in localStorage)) return {} const cached = JSON.parse(localStorage.getItem('event_cache')) @@ -625,14 +542,12 @@ function load_events(damus) }, {}) } -function load_cache(damus) -{ +function load_cache(damus) { damus.all_events = load_events(damus) load_timelines(damus) } -function save_cache(damus) -{ +function save_cache(damus) { save_events(damus) save_timelines(damus) } @@ -775,15 +690,11 @@ function send_home_filters(model, relay) { const ids = model.ids const friends = contacts_friend_list(model.contacts) friends.push(model.pubkey) - const contacts_filter = {kinds: [0], authors: friends} const dms_filter = {kinds: [4], "#p": [ model.pubkey ], limit: 100} const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 100} - const standard_kinds = [1,42,5,6,7] - const home_filter = {kinds: standard_kinds, authors: friends, limit: 500} - // TODO: include pow+fof spam filtering in notifications query const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100} @@ -796,15 +707,14 @@ function send_home_filters(model, relay) { if (relay) { last_of_kind = model.last_event_of_kind[relay] = - model.last_event_of_kind[relay] || calculate_last_of_kind(model.all_events) - + model.last_event_of_kind[relay] || calculate_last_of_kind(model.all_events); log_debug("last_of_kind", last_of_kind) } - 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) + 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) @@ -833,9 +743,8 @@ function update_filter_with_since(last_of_kind, filter) { return since < earliest ? since : earliest }, initial) - if (earliest) - filter.since = earliest + filter.since = earliest; } function update_filters_with_since(last_of_kind, filters) { @@ -844,127 +753,7 @@ function update_filters_with_since(last_of_kind, filters) { } } -function contacts_friend_list(contacts) { - return Array.from(contacts.friends) -} -function contacts_friendosphere(contacts) { - let s = new Set() - let fs = [] - - for (const friend of contacts.friends.keys()) { - fs.push(friend) - s.add(friend) - } - - for (const friend of contacts.friend_of_friends.keys()) { - if (!s.has(friend)) - fs.push(friend) - } - - return fs -} - -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.add(contact.pubkey) - - for (const tag of contact.tags) { - if (tag.length >= 2 && tag[0] == "p") { - if (!contact_is_friend(contacts, tag[1])) - contacts.friend_of_friends.add(tag[1]) - } - } -} - -function get_view_el(name) -{ - return DAMUS.view_el.querySelector(`#${name}-view`) -} - -function switch_view(name, opts={}) -{ - if (name === DAMUS.current_view) { - log_debug("Not switching to '%s', we are already there", name) - return - } - - const last = get_current_view() - if (!last) { - // render initial - DAMUS.current_view = name - redraw_timeline_events(DAMUS, name) - return - } - - log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view) - - DAMUS.current_view = name - const current = get_current_view() - const last_el = get_view_el(last.name) - const current_el = get_view_el(current.name) - - if (last_el) - last_el.classList.add("hide"); - - // TODO accomodate views that do not render events - // TODO find out if having multiple event divs is slow - //redraw_timeline_events(DAMUS, name) - - find_node("#nav > div[data-active]").dataset.active = name; - - if (current_el) - current_el.classList.remove("hide"); -} - -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)) { - if (!pool.has(relay)) { - log_debug("adding relay", relay) - pool.add(relay) - } - } -} - -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 handle_profiles_loaded(ids, model, view, relay) { // stop asking for profiles @@ -989,32 +778,8 @@ function handle_profiles_loaded(ids, model, view, relay) { model.pool.subscribe(ids.explore, explore_filters, relay) } -function redraw_my_pfp(model, force = false) { - const p = model.profiles[model.pubkey] - if (!p) return; - const html = render_pfp(model.pubkey, p); - const el = document.querySelector(".my-userpic") - if (!force && el.dataset.loaded) return; - el.dataset.loaded = true; - el.innerHTML = html; -} - -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 - }); - }; -} - // load profiles after comment notes are loaded -function handle_comments_loaded(ids, model, events, relay) -{ +function handle_comments_loaded(ids, model, events, relay) { const pubkeys = events.reduce((s, ev) => { s.add(ev.pubkey) for (const tag of ev.tags) { @@ -1031,10 +796,8 @@ function handle_comments_loaded(ids, model, events, relay) const profile_filter = {kinds: [0,3], authors: authors} let filters = [] - if (authors.length > 0) filters.push(profile_filter) - if (filters.length === 0) { log_debug("No profiles filters to request...") return @@ -1045,64 +808,6 @@ function handle_comments_loaded(ids, model, events, relay) model.pool.subscribe(ids.profiles, filters, relay) } -function redraw_events(damus, view) { - //log_debug("redrawing events for", view) - view.rendered = new Set() - - const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`) - events_el.innerHTML = render_events(damus, view) -} - -function redraw_timeline_events(damus, name) { - const view = DAMUS.views[name] - const events_el = damus.view_el.querySelector(`#${name}-view > .events`) - - if (view.events.length > 0) { - redraw_events(damus, view) - } else { - events_el.innerHTML = render_loading_spinner() - } -} - -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 determine_event_refs_positionally(pubkeys, ids) { if (ids.length === 1) @@ -1148,462 +853,46 @@ function determine_event_refs(tags) { return {root, reply, pubkeys} } -function* yield_etags(tags) -{ - for (const tag of tags) { - if (tag.length >= 2 && tag[0] === "e") - yield tag - } +function contacts_friend_list(contacts) { + return Array.from(contacts.friends) } -function expand_thread(id, reply_id) { - const view = get_current_view() - const root_id = get_thread_root_id(DAMUS, id) - if (!root_id) { - log_debug("could not get root_id for", DAMUS.all_events[id]) - return +function contacts_friendosphere(contacts) { + let s = new Set() + let fs = [] + + for (const friend of contacts.friends.keys()) { + fs.push(friend) + s.add(friend) } - view.expanded.add(reply_id) - view.depths[root_id] = get_thread_max_depth(DAMUS, view, root_id) + 1 - - redraw_events(DAMUS, view) -} - -function get_thread_root_id(damus, id) -{ - const ev = damus.all_events[id] - if (!ev) { - log_debug("expand_thread: no event found?", id) - return null + for (const friend of contacts.friend_of_friends.keys()) { + if (!s.has(friend)) + fs.push(friend) } - return ev.refs && ev.refs.root + return fs } -function get_default_max_depth(damus, view) -{ - return view.max_depth || damus.max_depth -} - -function get_thread_max_depth(damus, view, root_id) -{ - if (!view.depths[root_id]) - return get_default_max_depth(damus, view) - - return view.depths[root_id] -} - -function delete_post_confirm(evid) { - if (!confirm("Are you sure you want to delete this post?")) +function add_contact_if_friend(contacts, ev) { + if (!contact_is_friend(contacts, ev.pubkey)) 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) + add_friend_contact(contacts, ev) } -function shouldnt_render_event(our_pk, view, ev, opts) { - return !opts.is_composing && - !view.expanded.has(ev.id) && - view.rendered.has(ev.id) +function contact_is_friend(contacts, pk) { + return contacts.friends.has(pk) } -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() - } -} +function add_friend_contact(contacts, contact) { + contacts.friends.add(contact.pubkey) -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 = [] - let ids = new Set() - - if (from.refs && from.refs.root) { - tags.push(["e", from.refs.root, "", "root"]) - ids.add(from.refs.root) - } - - tags.push(["e", from.id, "", "reply"]) - ids.add(from.id) - - for (const tag of from.tags) { - if (tag.length >= 2) { - if (tag[0] === "p" && tag[1] !== pubkey) { - if (!ids.has(tag[1])) { - tags.push(["p", tag[1]]) - ids.add(tag[1]) - } - } + for (const tag of contact.tags) { + if (tag.length >= 2 && tag[0] == "p") { + if (!contact_is_friend(contacts, tag[1])) + contacts.friend_of_friends.add(tag[1]) } } - if (from.pubkey !== pubkey && !ids.has(from.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.all_events[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 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 -} - -async function get_nip05_pubkey(email) { - const [user, host] = email.split("@") - const url = `https://${host}/.well-known/nostr.json?name=${user}` - - try { - const res = await fetch(url) - const json = await res.json() - - log_debug("nip05 data", json) - return json.names[user] - } catch (e) { - log_error("fetching nip05 entry for %s", email, e) - throw e - } -} - -async function handle_pubkey(pubkey) { - if (pubkey[0] === "n") - pubkey = bech32_decode(pubkey) - - if (pubkey.includes("@")) - pubkey = await get_nip05_pubkey(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 await handle_pubkey(qs_pk) - - if (pubkey) - return pubkey - - console.log("window.nostr", window.nostr) - if (window.nostr && window.nostr.getPublicKey) { - console.log("calling window.nostr.getPublicKey()...") - const pubkey = await window.nostr.getPublicKey() - console.log("got %s pubkey from nos2x", pubkey) - return await handle_pubkey(pubkey) - } - - pubkey = prompt("Enter nostr id (eg: jb55@jb55.com) or pubkey (hex or npub)") - - if (!pubkey) - throw new Error("Need pubkey to continue") - - return await 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] - const view = get_current_view() - replying_to.innerHTML = render_event(DAMUS, view, ev, {is_composing: true, nobar: true, max_depth: 1}) - - replybox.focus() -} - -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(el) -{ - 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) - return robohash(pk) - if (profile.resolved_picture) - return profile.resolved_picture - profile.resolved_picture = sanitize(profile.picture) || robohash(pk) - return profile.resolved_picture -} - -function passes_spam_filter(contacts, ev, pow) -{ - if (contacts.friend_of_friends.has(ev.pubkey)) - return true - - return ev.pow >= pow } diff --git a/web/js/ui/fmt.js b/web/js/ui/fmt.js new file mode 100644 index 0000000..be92714 --- /dev/null +++ b/web/js/ui/fmt.js @@ -0,0 +1,74 @@ +function linkify(text, show_media) { + return text.replace(URL_REGEX, function(match, p1, p2, p3) { + const url = p2+p3 + const parsed = new URL(url) + let html; + if (show_media && is_img_url(parsed.pathname)) { + html = ` + + `; + } else if (show_media && is_video_url(parsed.pathname)) { + html = ` + + `; + } else { + html = `${url}`; + } + return p1+html; + }) +} + +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 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 + "
" + }, "") +} + diff --git a/web/js/ui/render.js b/web/js/ui/render.js index 3e2b477..b7dd500 100644 --- a/web/js/ui/render.js +++ b/web/js/ui/render.js @@ -14,7 +14,6 @@ function render_timeline_event(damus, view, ev) } function render_events(damus, view) { - log_debug("rendering events") return view.events .filter((ev, i) => i < 140) .map((ev) => render_timeline_event(damus, view, ev)).join("\n") @@ -350,3 +349,4 @@ function render_loading_spinner() ` } + diff --git a/web/js/ui/state.js b/web/js/ui/state.js new file mode 100644 index 0000000..cbaa378 --- /dev/null +++ b/web/js/ui/state.js @@ -0,0 +1,116 @@ +function get_view_el(name) { + return DAMUS.view_el.querySelector(`#${name}-view`) +} + +function get_default_max_depth(damus, view) { + return view.max_depth || damus.max_depth +} + +function get_thread_max_depth(damus, view, root_id) { + if (!view.depths[root_id]) + return get_default_max_depth(damus, view) + + return view.depths[root_id] +} + +function shouldnt_render_event(our_pk, view, ev, opts) { + return !opts.is_composing && + !view.expanded.has(ev.id) && + view.rendered.has(ev.id) +} + +function toggle_content_warning(el) { + 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 expand_thread(id, reply_id) { + const view = get_current_view() + const root_id = get_thread_root_id(DAMUS, id) + if (!root_id) { + log_debug("could not get root_id for", DAMUS.all_events[id]) + return + } + view.expanded.add(reply_id) + view.depths[root_id] = get_thread_max_depth(DAMUS, view, root_id) + 1 + redraw_events(DAMUS, view) +} + +function get_thread_root_id(damus, id) { + const ev = damus.all_events[id] + if (!ev) { + log_debug("expand_thread: no event found?", id) + return null + } + return ev.refs && ev.refs.root +} + +function redraw_events(damus, view) { + //log_debug("redrawing events for", view) + view.rendered = new Set() + const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`) + events_el.innerHTML = render_events(damus, view) +} + +function redraw_timeline_events(damus, name) { + const view = DAMUS.views[name] + const events_el = damus.view_el.querySelector(`#${name}-view > .events`) + if (view.events.length > 0) { + redraw_events(damus, view) + } else { + events_el.innerHTML = render_loading_spinner() + } +} + +function switch_view(name, opts={}) +{ + if (name === DAMUS.current_view) { + log_debug("Not switching to '%s', we are already there", name) + return + } + + const last = get_current_view() + if (!last) { + // render initial + DAMUS.current_view = name + redraw_timeline_events(DAMUS, name) + return + } + + log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view) + + DAMUS.current_view = name + const current = get_current_view() + const last_el = get_view_el(last.name) + const current_el = get_view_el(current.name) + + if (last_el) + last_el.classList.add("hide"); + + // TODO accomodate views that do not render events + // TODO find out if having multiple event divs is slow + //redraw_timeline_events(DAMUS, name) + + find_node("#nav > div[data-active]").dataset.active = name; + + if (current_el) + current_el.classList.remove("hide"); +} + +function get_current_view() +{ + // TODO resolve memory & html descriptencies + // Currently there is tracking of which divs are visible in HTML/CSS and + // which is active in memory, simply resolve this by finding the visible + // element instead of tracking it in memory (or remove dom elements). This + // would simplify state tracking IMO - Thomas + return DAMUS.views[DAMUS.current_view] +} + diff --git a/web/js/ui/util.js b/web/js/ui/util.js index 5e2cac8..1502c15 100644 --- a/web/js/ui/util.js +++ b/web/js/ui/util.js @@ -129,3 +129,136 @@ function open_media_preview(url, type) { function close_media_preview() { find_node("#media-preview").classList.add("closed"); } + +function close_reply() { + const modal = document.querySelector("#reply-modal") + modal.classList.add("closed"); +} + +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() + } +} + +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) +} + +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 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] + const view = get_current_view() + replying_to.innerHTML = render_event(DAMUS, view, ev, {is_composing: true, nobar: true, max_depth: 1}) + + replybox.focus() +} + +function redraw_my_pfp(model, force = false) { + const p = model.profiles[model.pubkey] + if (!p) return; + const html = render_pfp(model.pubkey, p); + const el = document.querySelector(".my-userpic") + if (!force && el.dataset.loaded) return; + el.dataset.loaded = true; + el.innerHTML = html; +} + +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; +} + +// update_title updates the document title & visual indicators based on if the +// number of notifications that are unseen by the user. +function update_title(model) { + // TODO rename update_title to update_notification_state or similar + // TODO only clear notifications once they have seen all targeted events + if (document.visibilityState === 'visible') { + model.notifications = 0 + } + + const num = model.notifications + const has_notes = num !== 0 + document.title = has_notes ? `(${num}) Damus` : "Damus"; + update_favicon(has_notes ? "img/damus_notif.svg" : "img/damus.svg"); + update_notification_markers(has_notes) +} + +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 await handle_pubkey(qs_pk) + if (pubkey) + return pubkey + if (window.nostr && window.nostr.getPublicKey) { + console.log("calling window.nostr.getPublicKey()...") + const pubkey = await window.nostr.getPublicKey() + console.log("got %s pubkey from nos2x", pubkey) + return await handle_pubkey(pubkey) + } + pubkey = prompt("Enter nostr id (eg: jb55@jb55.com) or pubkey (hex or npub)") + if (!pubkey) + throw new Error("Need pubkey to continue") + return await 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 +} + + diff --git a/web/js/util.js b/web/js/util.js index af45d2f..d5e8582 100644 --- a/web/js/util.js +++ b/web/js/util.js @@ -1,3 +1,9 @@ +const ID_REG = /^[a-f0-9]{64}$/ +const WORD_REGEX=/\w/ +const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i +const VID_REGEX = /(webm|mp4)$/i +const URL_REGEX = /(^|\s)(https?:\/\/[^\s]+)[,:)]?(\w|$)/g; +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])?))?)$/ function log_error(fmt, ...args) { console.error(fmt, ...args) @@ -7,6 +13,60 @@ function log_debug(fmt, ...args) { console.debug(fmt, ...args) } +function log_info(fmt, ...args) { + console.info(fmt, ...args) +} + +function safe_parse_json(data, message) { + let value = undefined; + try { + value = JSON.parse(data); + } catch (e) { + log_error(`${message} : unable to parse JSON`, err, data); + } + return value; +} + +function max(a, b) { + return a > b ? a : b +} + +/* min returns the minimum value of a or b + */ +function min(a, b) { + return a < b ? a : b; +} + +/* shuffle shuffles an Array + * TODO rename to shuffle_array + */ +function shuffle(arr) { + let i = arr.length; + while (--i > 0) { + let randIndex = Math.floor(Math.random() * (i + 1)); + [arr[randIndex], arr[i]] = [arr[i], arr[randIndex]]; + } + return arr; +} + +function is_valid_time(now_sec, created_at) { + // don't count events far in the future + if (created_at - now_sec >= 120) { + return false + } + return true +} + +function new_creation_time() { + return Math.floor(new Date().getTime() / 1000) +} + +function get_since_time(created_at) { + if (created_at == null) + return null + return created_at - 60 * 10 +} + /* find_node is a short name for document.querySelector, it also takes in a * parent element to search on. */ @@ -37,12 +97,6 @@ function bech32_decode(pubkey) { return nostrjs.hex_encode(bytes) } -function get_since_time(created_at) { - if (created_at == null) - return null - return created_at - 60 * 10 -} - function zero_bits(b) { let n = 0; if (b == 0) return 8; @@ -65,12 +119,6 @@ function leading_zero_bits(id) { return total } -/* min returns the minimum value of a or b - */ -function min(a, b) { - return a < b ? a : b; -} - function difficulty_to_prefix(d) { const n = Math.floor(d / 4) let s = "" @@ -140,6 +188,16 @@ function time_delta(current, previous) { } } +function sanitize(content) { + if (!content) + return "" + return content.replaceAll("<","<").replaceAll(">",">") +} + +function robohash(str) { + return "https://robohash.org/" + str +} + function is_valid_reaction_content(content) { return content === "+" || content === "" || is_emoji(content) } @@ -151,43 +209,51 @@ function get_reaction_emoji(ev) { return ev.content } -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 = /(^|\s)(https?:\/\/[^\s]+)[,:)]?(\w|$)/g; -function linkify(text, show_media) { - return text.replace(URL_REGEX, function(match, p1, p2, p3) { - const url = p2+p3 - const parsed = new URL(url) - let html; - if (show_media && is_img_url(parsed.pathname)) { - html = ` - - `; - } else if (show_media && is_video_url(parsed.pathname)) { - html = ` - - `; - } else { - html = `${url}`; - } - return p1+html; - }) -} - -const WORD_REGEX=/\w/ function is_emoji(str) { return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) } -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])?))?)$/ +function is_valid_id(evid) +{ + return ID_REG.test(evid) +} + +function get_qs(loc=location.href) { + return new URL(loc).searchParams +} + +function get_picture(pk, profile) { + if (!profile) + return robohash(pk) + if (profile.resolved_picture) + return profile.resolved_picture + profile.resolved_picture = sanitize(profile.picture) || robohash(pk) + return profile.resolved_picture +} + +function passes_spam_filter(contacts, ev, pow) { + if (contacts.friend_of_friends.has(ev.pubkey)) + return true + return ev.pow >= pow +} + +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 + }); + }; +}