From 00edee1cb3febe4ce94505b61802937e24e26256 Mon Sep 17 00:00:00 2001 From: Thomas Mathews Date: Thu, 15 Dec 2022 23:34:41 -0800 Subject: [PATCH 1/3] WIP --- web/index.html | 7 + web/js/cache.js | 89 +++++ web/js/contacts.js | 49 +++ web/js/damus.js | 896 ++++----------------------------------------- web/js/event.js | 107 ++++++ web/js/filters.js | 106 ++++++ web/js/lib.js | 187 ++++++++++ web/js/model.js | 248 +++++++++++++ web/js/unknowns.js | 107 ++++++ web/js/util.js | 43 +-- 10 files changed, 977 insertions(+), 862 deletions(-) create mode 100644 web/js/cache.js create mode 100644 web/js/contacts.js create mode 100644 web/js/event.js create mode 100644 web/js/filters.js create mode 100644 web/js/lib.js create mode 100644 web/js/model.js create mode 100644 web/js/unknowns.js diff --git a/web/index.html b/web/index.html index 6488152..51dd49e 100644 --- a/web/index.html +++ b/web/index.html @@ -17,6 +17,13 @@ + + + + + + + diff --git a/web/js/cache.js b/web/js/cache.js new file mode 100644 index 0000000..a0982cc --- /dev/null +++ b/web/js/cache.js @@ -0,0 +1,89 @@ +function load_events(damus) { + if (!('event_cache' in localStorage)) + return {} + const cached = JSON.parse(localStorage.getItem('event_cache')) + + return cached.reduce((obj, ev) => { + obj[ev.id] = ev + process_event(damus, ev) + return obj + }, {}) +} + +function load_cache(damus) { + damus.all_events = load_events(damus) + load_timelines(damus) +} + +function save_cache(damus) { + save_events(damus) + save_timelines(damus) +} + +function save_events(damus) +{ + const keys = Object.keys(damus.all_events) + const MAX_KINDS = { + 1: 2000, + 0: 2000, + + 6: 100, + 4: 100, + 5: 100, + 7: 100, + } + + let counts = {} + + let cached = keys.map((key) => { + const ev = damus.all_events[key] + const {sig, pubkey, content, tags, kind, created_at, id} = ev + return {sig, pubkey, content, tags, kind, created_at, id} + }) + + cached.sort((a,b) => b.created_at - a.created_at) + cached = cached.reduce((cs, ev) => { + counts[ev.kind] = (counts[ev.kind] || 0)+1 + if (counts[ev.kind] < MAX_KINDS[ev.kind]) + cs.push(ev) + return cs + }, []) + + log_debug('saving all events to local storage', cached.length) + + localStorage.setItem('event_cache', JSON.stringify(cached)) +} + +function save_timelines(damus) +{ + const views = Object.keys(damus.views).reduce((obj, view_name) => { + const view = damus.views[view_name] + obj[view_name] = view.events.map(e => e.id).slice(0,100) + return obj + }, {}) + localStorage.setItem('views', JSON.stringify(views)) +} + +function load_timelines(damus) +{ + if (!('views' in localStorage)) + return + const stored_views = JSON.parse(localStorage.getItem('views')) + for (const view_name of Object.keys(damus.views)) { + const view = damus.views[view_name] + view.events = (stored_views[view_name] || []).reduce((evs, evid) => { + const ev = damus.all_events[evid] + if (ev) evs.push(ev) + return evs + }, []) + } +} + +function schedule_save_events(damus) +{ + if (damus.save_timer) + clearTimeout(damus.save_timer) + damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000) +} + + diff --git a/web/js/contacts.js b/web/js/contacts.js new file mode 100644 index 0000000..56529f3 --- /dev/null +++ b/web/js/contacts.js @@ -0,0 +1,49 @@ +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 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 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]) + } + } +} + diff --git a/web/js/damus.js b/web/js/damus.js index 33243b5..163b686 100644 --- a/web/js/damus.js +++ b/web/js/damus.js @@ -1,9 +1,11 @@ let DAMUS +const STANDARD_KINDS = [1, 42, 5, 6, 7]; + const BOOTSTRAP_RELAYS = [ "wss://relay.damus.io", - "wss://nostr-relay.wlvs.space", - "wss://nostr-pub.wellorder.net" + //"wss://nostr-relay.wlvs.space", + //"wss://nostr-pub.wellorder.net" ] const DEFAULT_PROFILE = { @@ -30,869 +32,101 @@ async function damus_web_init() { } async function damus_web_init_ready() { - const model = init_home_model() + const model = new_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(), + 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(), + unknowns: "unknowns",//uuidv4(), + dms: "dms",//uuidv4(), } model.ids = ids model.pool = pool - load_cache(model) model.view_el = document.querySelector("#view") + //load_cache(model) 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) - } - }) + pool.on("open", on_pool_open); + pool.on("event", on_pool_event); + pool.on("notice", on_pool_notice); + pool.on("eose", on_pool_eose); 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 -} - -function init_contacts() { - return { - event: null, - friends: new Set(), - friend_of_friends: new Set(), - } -} - -function init_timeline(name) { - return { - name, - events: [], - rendered: new Set(), - depths: {}, - expanded: new Set(), - } -} - -function init_home_model() { - return { - done_init: {}, - notifications: 0, - max_depth: 2, - all_events: {}, - reactions_to: {}, - chatrooms: {}, - unknown_ids: {}, - unknown_pks: {}, - deletions: {}, - but_wait_theres_more: 0, - cw_open: {}, - views: { - home: init_timeline('home'), - explore: { - ...init_timeline('explore'), - seen: new Set(), - }, - notifications: { - ...init_timeline('notifications'), - max_depth: 1, - }, - profile: init_timeline('profile'), - thread: init_timeline('thread'), - }, - pow: 0, // pow difficulty target - deleted: {}, - profiles: {}, - profile_events: {}, - last_event_of_kind: {}, - contacts: init_contacts() - } -} - -function notice_chatroom(state, id) { - if (!state.chatrooms[id]) - state.chatrooms[id] = {} -} - -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) { - model.chatrooms[ev.id] = safe_parse_json(ev.content, - "chatroom create event"); -} - -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_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 - // deleted event yet. - ds.add(ev.id) - } - } -} - -// 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] - - 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 has_profile(damus, pk) { - return pk in damus.profiles -} - -function has_event(damus, evid) { - return evid in damus.all_events -} - -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) { - // make sure this event itself is removed from unknowns - if (ev.kind === 0) - delete damus.unknown_pks[ev.pubkey] - delete damus.unknown_ids[ev.id] - - let got_some = false - - for (const tag of ev.tags) { - if (tag.length >= 2) { - if (tag[0] === "p") { - const pk = tag[1] - if (!has_profile(damus, pk) && is_valid_id(pk)) { - got_some = true - damus.unknown_pks[pk] = make_unk(tag[2], ev) - } - } else if (tag[0] === "e") { - const evid = tag[1] - if (!has_event(damus, evid) && is_valid_id(evid)) { - got_some = true - damus.unknown_ids[evid] = make_unk(tag[2], ev) - } - } - } - } - - return got_some -} - -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) -} - -function get_non_expired_unknowns(unks, type) -{ - const MAX_ATTEMPTS = 2 - - function sort_parent_created(a_id, b_id) { - const a = unks[a_id] - const b = unks[b_id] - return b.parent_created - a.parent_created - } - - let new_expired = 0 - const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => { - if (ids.length >= 255) - return ids - - const unk = unks[unk_id] - if (unk.attempts >= MAX_ATTEMPTS) { - if (!unk.expired) { - unk.expired = true - new_expired++ - } - return ids - } - - unk.attempts++ - - ids.push(unk_id) - return ids - }, []) - - if (new_expired !== 0) - log_debug("Gave up looking for %d %s", new_expired, type) - - return ids -} - -function fetch_unknown_events(damus) -{ - let filters = [] - - 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 schedule_unknown_refetch(damus) -{ - const INTERVAL = 5000 - if (!damus.unknown_timer) { - log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000) - - damus.unknown_timer = setTimeout(() => { - fetch_unknown_events(damus) - - setTimeout(() => { - delete damus.unknown_timer - if (damus.but_wait_theres_more > 0) { - damus.but_wait_theres_more = 0 - schedule_unknown_refetch(damus) - } - }, INTERVAL) - }, INTERVAL) - - fetch_unknown_events(damus) +function on_pool_open(relay) { + console.info("opened relay", relay); + const model = DAMUS; + // We check the cache if we have init anything, if not we do our inital + // otherwise we do a get since last + if (!model.done_init[relay]) { + // if we have not init'd the relay simply get our initial filters + relay.subscribe(model.ids.account, filter_new_initial(model.pk)); } else { - damus.but_wait_theres_more++ + // otherwise let's get everythign since the last time + model_subscribe_defaults(model, relay); } } -function process_event(damus, ev) -{ - ev.refs = determine_event_refs(ev.tags) - const notified = was_pubkey_notified(damus.pubkey, ev) - ev.notified = notified - - const got_some_unknowns = notice_unknown_ids(damus, ev) - if (got_some_unknowns) - schedule_unknown_refetch(damus) - - ev.pow = calculate_pow(ev) - - if (ev.kind === 7) - process_reaction_event(damus, ev) - else if (ev.kind === 42 && ev.refs && ev.refs.root) - notice_chatroom(damus, ev.refs.root) - else if (ev.kind === 40) - process_chatroom_event(damus, ev) - else if (ev.kind === 5) - process_deletion_event(damus, ev) - else if (ev.kind === 0) - process_profile_event(damus, ev) - else if (ev.kind === 3) - process_contact_event(damus, 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()) - damus.notifications++ - update_title(damus) - } +function on_pool_notice(relay, notice) { + console.info("notice", notice); + // DO NOTHING } -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_notification_timeline(our_pk, contacts, ev, pow) -{ - if (!should_add_to_timeline(ev)) - return false - - // TODO: add items that don't pass spam filter to "message requests" - // Then we will need a way to whitelist people as an alternative to - // following them - return passes_spam_filter(contacts, ev, pow) -} - -function should_add_to_explore_timeline(contacts, view, ev, pow) -{ - if (!should_add_to_timeline(ev)) - return false - - if (view.seen.has(ev.pubkey)) - return false - - // hide friends for 0-pow situations - if (pow === 0 && contacts.friends.has(ev.pubkey)) - return false - - return passes_spam_filter(contacts, ev, pow) -} - -function handle_redraw_logic(model, view_name) -{ - const view = model.views[view_name] - if (view.redraw_timer) - clearTimeout(view.redraw_timer) - view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600) -} - -function schedule_save_events(damus) -{ - if (damus.save_timer) - clearTimeout(damus.save_timer) - damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000) -} - -function calculate_last_of_kind(evs) { - const now_sec = new Date().getTime() / 1000 - return Object.keys(evs).reduce((obj, evid) => { - const ev = evs[evid] - if (!is_valid_time(now_sec, ev.created_at)) - return obj - const prev = obj[ev.kind] || 0 - obj[ev.kind] = get_since_time(max(ev.created_at, prev)) - return obj - }, {}) -} - -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')) - - return cached.reduce((obj, ev) => { - obj[ev.id] = ev - process_event(damus, ev) - return obj - }, {}) -} - -function load_cache(damus) { - damus.all_events = load_events(damus) - load_timelines(damus) -} - -function save_cache(damus) { - save_events(damus) - save_timelines(damus) -} - -function save_events(damus) -{ - const keys = Object.keys(damus.all_events) - const MAX_KINDS = { - 1: 2000, - 0: 2000, - - 6: 100, - 4: 100, - 5: 100, - 7: 100, - } - - let counts = {} - - let cached = keys.map((key) => { - const ev = damus.all_events[key] - const {sig, pubkey, content, tags, kind, created_at, id} = ev - return {sig, pubkey, content, tags, kind, created_at, id} - }) - - cached.sort((a,b) => b.created_at - a.created_at) - cached = cached.reduce((cs, ev) => { - counts[ev.kind] = (counts[ev.kind] || 0)+1 - if (counts[ev.kind] < MAX_KINDS[ev.kind]) - cs.push(ev) - return cs - }, []) - - log_debug('saving all events to local storage', cached.length) - - localStorage.setItem('event_cache', JSON.stringify(cached)) -} - -function save_timelines(damus) -{ - const views = Object.keys(damus.views).reduce((obj, view_name) => { - const view = damus.views[view_name] - obj[view_name] = view.events.map(e => e.id).slice(0,100) - return obj - }, {}) - localStorage.setItem('views', JSON.stringify(views)) -} - -function load_timelines(damus) -{ - if (!('views' in localStorage)) - return - const stored_views = JSON.parse(localStorage.getItem('views')) - for (const view_name of Object.keys(damus.views)) { - const view = damus.views[view_name] - view.events = (stored_views[view_name] || []).reduce((evs, evid) => { - const ev = damus.all_events[evid] - if (ev) evs.push(ev) - return evs - }, []) - } -} - -function handle_home_event(model, relay, sub_id, ev) { - const ids = model.ids - - // ignore duplicates - if (!has_event(model, ev.id)) { - model.all_events[ev.id] = ev - process_event(model, ev) - schedule_save_events(model) - } - - ev = model.all_events[ev.id] - - let is_new = true +// TODO document what EOSE is +async function on_pool_eose(relay, sub_id) { + console.info("eose", relay.url, sub_id); + const model = DAMUS; + const { ids, pool } = model; switch (sub_id) { - case model.ids.explore: - const view = model.views.explore - - // show more things in explore timeline - if (should_add_to_explore_timeline(model.contacts, view, ev, model.pow)) { - view.seen.add(ev.pubkey) - is_new = insert_event_sorted(view.events, ev) - } - - if (is_new) - handle_redraw_logic(model, 'explore') - break; - - case model.ids.notifications: - if (should_add_to_notification_timeline(model.pubkey, model.contacts, ev, model.pow)) - is_new = insert_event_sorted(model.views.notifications.events, ev) - - if (is_new) - handle_redraw_logic(model, 'notifications') - break; - - case model.ids.home: - if (should_add_to_timeline(ev)) - is_new = insert_event_sorted(model.views.home.events, ev) - - if (is_new) - handle_redraw_logic(model, 'home') - break; - case model.ids.account: - switch (ev.kind) { - case 3: - model.done_init[relay] = true - model.pool.unsubscribe(model.ids.account, relay) - send_home_filters(model, relay) - break - } - break - case model.ids.profiles: - break + case ids.home: + //const events = model.views.home.events + //handle_comments_loaded(ids, model, events, relay) + break; + case ids.profiles: + //const view = get_current_view() + //handle_profiles_loaded(ids, model, view, relay) + break; + case ids.unknown: + // TODO document why we unsub from unknowns + pool.unsubscribe(ids.unknowns, relay); + break; } } -function process_profile_event(model, ev) { - const prev_ev = model.all_events[model.profile_events[ev.pubkey]] - if (prev_ev && prev_ev.created_at > ev.created_at) - return - - model.profile_events[ev.pubkey] = ev.id - 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(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} - - 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] || calculate_last_of_kind(model.all_events); - log_debug("last_of_kind", last_of_kind) +function on_pool_event(relay, sub_id, ev) { + console.info("event", relay.url, sub_id, ev); + const model = DAMUS; + const { ids, pool } = model; + + if (!model.all_events[ev.id]) { + model.all_events[ev.id] = ev; + model_process_event(model, ev); + // schedule_save_events(model); } - 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 update_filter_with_since(last_of_kind, filter) { - const kinds = filter.kinds || [] - let initial = null - let earliest = kinds.reduce((earliest, kind) => { - const last_created_at = last_of_kind[kind] - let since = get_since_time(last_created_at) - - 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 handle_profiles_loaded(ids, model, view, relay) { - // stop asking for profiles - model.pool.unsubscribe(ids.profiles, relay) - - //redraw_events(model, view) - redraw_my_pfp(model) - - const prefix = difficulty_to_prefix(model.pow) - const fofs = Array.from(model.contacts.friend_of_friends) - const standard_kinds = [1,42,5,6,7] - let pow_filter = {kinds: standard_kinds, limit: 50} - if (model.pow > 0) - pow_filter.ids = [ prefix ] - - let explore_filters = [ pow_filter ] - - if (fofs.length > 0) { - explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50}) + switch (sub_id) { + case ids.account: + model.done_init[relay] = true; + pool.unsubscribe(ids.account, relay); + model_subscribe_defaults(model, relay); + break; } - model.pool.subscribe(ids.explore, explore_filters, relay) -} - -// load profiles after comment notes are loaded -function handle_comments_loaded(ids, model, events, relay) { - const pubkeys = events.reduce((s, ev) => { - s.add(ev.pubkey) - for (const tag of ev.tags) { - if (tag.length >= 2 && tag[0] === "p") { - if (!model.profile_events[tag[1]]) - s.add(tag[1]) - } - } - return s - }, new Set()) - const authors = Array.from(pubkeys) - - // load profiles and noticed chatrooms - 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 - } - - //console.log("subscribe", profiles_id, filter, relay) - log_debug("subscribing to profiles on %s", relay.url) - model.pool.subscribe(ids.profiles, filters, relay) -} - -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") { - positional_ids.push(tag[1]) - 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) - - /* - if (reply && !root) - root = reply - */ - - return {root, reply, pubkeys} -} - -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 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]) - } - } + // TODO do smart view update logic here } diff --git a/web/js/event.js b/web/js/event.js new file mode 100644 index 0000000..cdbf564 --- /dev/null +++ b/web/js/event.js @@ -0,0 +1,107 @@ +/* EVENT */ + +/* event_refs_pubkey checks if the event (ev) is directed at the public key + * (pubkey) by checking its type, tags, and key. + */ +function event_refs_pubkey(ev, pubkey) { + 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 event_calculate_pow(ev) { + const id_bits = leading_zero_bits(ev.id) + for (const tag of ev.tags) { + if (tag.length >= 3 && tag[0] === "nonce") { + const target = +tag[2] + if (isNaN(target)) + return 0 + + // if our nonce target is smaller than the difficulty, + // then we use the nonce target as the actual difficulty + return min(target, id_bits) + } + } + + // not a valid pow if we don't have a difficulty target + return 0 +} + +/* event_can_reply returns a boolean value based on if you can "reply" to the + * event in the manner of a chat. + */ +function event_can_reply(ev) { + return ev.kind === 1 || ev.kind === 42 +} + +/* event_is_timeline returns a boolean based on if the event should be rendered + * in a GUI + */ +function event_is_timeline(ev) { + return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 +} + +function event_get_tag_refs(tags) { + let ids = [] + let pubkeys = [] + let root, reply + let i = 0 + for (const tag of tags) { + if (tag.length >= 4 && tag[0] == "e") { + ids.push(tag[1]) + if (tag[3] === "root") { + root = tag[1] + } else if (tag[3] === "reply") { + reply = tag[1] + } + } else if (tag.length >= 2 && tag[0] == "e") { + ids.push(tag[1]) + } else if (tag.length >= 2 && tag[0] == "p") { + pubkeys.push(tag[1]) + } + i++ + } + if (!(root && reply) && ids.length > 0) { + 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} + } + return {root, reply, pubkeys} +} + +function events_insert_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 passes_spam_filter(contacts, ev, pow) { + log_warn("passes_spam_filter deprecated, use event_is_spam"); + return !event_is_spam(ev, contacts, pow); +} + +function event_is_spam(ev, contacts, pow) { + if (contacts.friend_of_friends.has(ev.pubkey)) + return true + return ev.pow >= pow +} + + + diff --git a/web/js/filters.js b/web/js/filters.js new file mode 100644 index 0000000..d37d2e0 --- /dev/null +++ b/web/js/filters.js @@ -0,0 +1,106 @@ +function filters_subscribe(filters, pool, relays=undefined) { + for (const key in filters) { + pool.subscribe(key, filters[key], relays); + } +} + +function filters_new_default(model) { + const { pubkey, ids, contacts } = model; + const friends = contacts_friend_list(contacts); + friends.push(pubkey); + const f = {}; + f[ids.home] = filters_new_friends(friends); + f[ids.contacts] = filters_new_contacts(friends); + f[ids.dms] = filters_new_dms(pubkey); + f[ids.notifications] = filters_new_notifications(pubkey); + return f; +} + +function filters_new_default_since(model, cache) { + const filters = filters_new_default(model); + for (const key in filters) { + filters[key] = filters_set_since(filters[key], cache); + } + return filters; +} + +function filter_new_initial(pubkey) { + return { + authors: [pubkey], + kinds: [3], + limit: 1, + }; +} + +function filters_new_contacts(friends) { + return [{ + kinds: [0], + authors: friends, + }] +} + +function filters_new_dms(pubkey, limit=100) { + return [ + { // dms we sent + kinds: [4], + limit, + authors: [pubkey], + }, { // replys to us + kinds: [4], + limit: limit, + "#p": [pubkey], + }] +} + +function filters_new_friends(friends, limit=500) { + return [{ + kinds: STANDARD_KINDS, + authors: friends, + limit, + }] +} + +function filters_new_notifications(pubkey, limit=100) { + return [{ + kinds: STANDARD_KINDS, + "#p": [pubkey], + limit, + }] +} + +function filters_set_since(filters=[], cache={}) { + filters.forEach((filter) => { + const since = get_earliest_since_time(filter.kinds, cache) + delete filter.since; + if (since) filter.since = since; + }); + return filters +} + + +function get_earliest_since_time(kinds=[], cache={}) { + const earliest = kinds.reduce((a, kind) => { + const b = get_since_time(cache[kind]); + if (!a) { + return b; + } + return b < a ? b : a; + }, undefined); + return earliest; +} + +/* calculate_last_of_kind returns a map of kinds to time, where time is the + * last time it saw that kind. + */ +function calculate_last_of_kind(evs) { + const now_sec = new Date().getTime() / 1000 + return Object.keys(evs).reduce((obj, evid) => { + const ev = evs[evid] + if (!is_valid_time(now_sec, ev.created_at)) + return obj + const prev = obj[ev.kind] || 0 + obj[ev.kind] = get_since_time(max(ev.created_at, prev)) + return obj + }, {}) +} + diff --git a/web/js/lib.js b/web/js/lib.js new file mode 100644 index 0000000..e0e01a8 --- /dev/null +++ b/web/js/lib.js @@ -0,0 +1,187 @@ +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 notice_chatroom(state, id) { + if (!state.chatrooms[id]) + state.chatrooms[id] = {} +} + +function should_add_to_notification_timeline(our_pk, contacts, ev, pow) +{ + if (!should_add_to_timeline(ev)) + return false + + // TODO: add items that don't pass spam filter to "message requests" + // Then we will need a way to whitelist people as an alternative to + // following them + return passes_spam_filter(contacts, ev, pow) +} + +function should_add_to_explore_timeline(contacts, view, ev, pow) +{ + if (!should_add_to_timeline(ev)) + return false + + if (view.seen.has(ev.pubkey)) + return false + + // hide friends for 0-pow situations + if (pow === 0 && contacts.friends.has(ev.pubkey)) + return false + + return passes_spam_filter(contacts, ev, pow) +} + +function handle_redraw_logic(model, view_name) +{ + const view = model.views[view_name] + if (view.redraw_timer) + clearTimeout(view.redraw_timer) + view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600) +} + +/* +function handle_home_event(model, relay, sub_id, ev) { + const ids = model.ids + + // ignore duplicates + if (!has_event(model, ev.id)) { + model.all_events[ev.id] = ev + process_event(model, ev) + schedule_save_events(model) + } + + ev = model.all_events[ev.id] + + let is_new = true + switch (sub_id) { + case model.ids.explore: + const view = model.views.explore + + // show more things in explore timeline + if (should_add_to_explore_timeline(model.contacts, view, ev, model.pow)) { + view.seen.add(ev.pubkey) + is_new = insert_event_sorted(view.events, ev) + } + + if (is_new) + handle_redraw_logic(model, 'explore') + break; + + case model.ids.notifications: + if (should_add_to_notification_timeline(model.pubkey, model.contacts, ev, model.pow)) + is_new = insert_event_sorted(model.views.notifications.events, ev) + + if (is_new) + handle_redraw_logic(model, 'notifications') + break; + + case model.ids.home: + if (should_add_to_timeline(ev)) + is_new = insert_event_sorted(model.views.home.events, ev) + + if (is_new) + handle_redraw_logic(model, 'home') + break; + case model.ids.account: + switch (ev.kind) { + case 3: + model.done_init[relay] = true + model.pool.unsubscribe(model.ids.account, relay) + send_home_filters(model, relay) + break + } + break + case model.ids.profiles: + break + } +}*/ + +function handle_profiles_loaded(ids, model, view, relay) { + // stop asking for profiles + model.pool.unsubscribe(ids.profiles, relay) + + //redraw_events(model, view) + redraw_my_pfp(model) + + const prefix = difficulty_to_prefix(model.pow) + const fofs = Array.from(model.contacts.friend_of_friends) + const standard_kinds = [1,42,5,6,7] + let pow_filter = {kinds: standard_kinds, limit: 50} + if (model.pow > 0) + pow_filter.ids = [ prefix ] + + let explore_filters = [ pow_filter ] + + if (fofs.length > 0) { + explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50}) + } + + model.pool.subscribe(ids.explore, explore_filters, relay) +} + +// load profiles after comment notes are loaded +function handle_comments_loaded(ids, model, events, relay) { + const pubkeys = events.reduce((s, ev) => { + s.add(ev.pubkey) + for (const tag of ev.tags) { + if (tag.length >= 2 && tag[0] === "p") { + if (!model.profile_events[tag[1]]) + s.add(tag[1]) + } + } + return s + }, new Set()) + const authors = Array.from(pubkeys) + + // load profiles and noticed chatrooms + 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 + } + + //console.log("subscribe", profiles_id, filter, relay) + log_debug("subscribing to profiles on %s", relay.url) + model.pool.subscribe(ids.profiles, filters, relay) +} + +/* DEPRECATED */ + +function is_deleted(model, evid) { + log_warn("is_deleted deprecated, use model_is_event_deleted"); + return model_is_event_deleted(model, evid); +} +function process_event(model, ev) { + log_warn("process_event deprecated, use event_process"); + return model_process_event(model, ev); +} +function calculate_pow(ev) { + log_warn("calculate_pow deprecated, use event_calculate_pow"); + return event_calculate_pow(ev); +} +function insert_event_sorted(evs, new_ev) { + log_warn("insert_event_sorted deprecated, use events_insert_sorted"); + events_insert_sorted(evs, new_ev); +} diff --git a/web/js/model.js b/web/js/model.js new file mode 100644 index 0000000..c10baed --- /dev/null +++ b/web/js/model.js @@ -0,0 +1,248 @@ +/* model_process_event is the main point where events are post-processed from + * a relay. Additionally other side effects happen such as notification checks + * and fetching of unknown pubkey profiles. + */ +function model_process_event(model, ev) { + ev.refs = event_get_tag_refs(ev.tags) + ev.pow = event_calculate_pow(ev) + ev.is_spam = !event_is_spam(ev, model.contacts, model.pow); + + // Process specific event needs based on it's kind. Not using a map because + // integers can't be used. + let fn; + switch(ev.kind) { + case 0: + fn = model_process_event_profile; + break; + case 3: + fn = model_process_event_contact; + break; + case 5: + fn = model_process_event_deletion; + break; + case 7: + fn = model_process_event_reaction; + break; + } + if (fn) + fn(model, ev); + + // not handling chatrooms for now + // else if (ev.kind === 42 && ev.refs && ev.refs.root) + // notice_chatroom(damus, ev.refs.root) + // else if (ev.kind === 40) + // event_process_chatroom(damus, ev) + + // check if the event that just came in should notify the user and is newer + // than the last recorded notification event, if it is notify + const notify_user = event_refs_pubkey(ev, model.pubkey) + const last_notified = get_local_state('last_notified_date') + ev.notified = notify_user; + if (notify_user && (last_notified == null || ((ev.created_at*1000) > last_notified))) { + set_local_state('last_notified_date', new Date().getTime()); + model.notifications++; + update_title(model); + } + + // If we find some unknown ids lets schedule their subscription for info + if (model_event_has_unknown_ids(model, ev)) + schedule_unknown_refetch(model); +} + +//function process_chatroom_event(model, ev) { +// model.chatrooms[ev.id] = safe_parse_json(ev.content, +// "chatroom create event"); +//} + +/* model_process_event_profile updates the matching profile with the contents found + * in the event. + */ +function model_process_event_profile(model, ev) { + const prev_ev = model.all_events[model.profile_events[ev.pubkey]] + if (prev_ev && prev_ev.created_at > ev.created_at) + return + model.profile_events[ev.pubkey] = ev.id + model.profiles[ev.pubkey] = safe_parse_json(ev.content, "profile contents") +} + +/* model_process_event_reaction updates the reactions dictionary + */ +function model_process_event_reaction(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 model_process_event_contact(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) +} + +/* event_process_deletion updates the list of deleted events. + */ +function model_process_event_deletion(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) + } + } +} + +/* model_event_has_unknown_ids checks the event if there are any referenced keys with + * unknown user profiles in the provided scope. + */ +function model_event_has_unknown_ids(damus, ev) { + // make sure this event itself is removed from unknowns + if (ev.kind === 0) + delete damus.unknown_pks[ev.pubkey] + delete damus.unknown_ids[ev.id] + let got_some = false + for (const tag of ev.tags) { + if (tag.length >= 2) { + if (tag[0] === "p") { + const pk = tag[1] + if (!model_has_profile(damus, pk) && is_valid_id(pk)) { + got_some = true + damus.unknown_pks[pk] = make_unk(tag[2], ev) + } + } else if (tag[0] === "e") { + const evid = tag[1] + if (!model_has_event(damus, evid) && is_valid_id(evid)) { + got_some = true + damus.unknown_ids[evid] = make_unk(tag[2], ev) + } + } + } + } + return got_some +} + +function model_is_event_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 model_has_profile(model, pk) { + return pk in model.profiles +} + +function model_has_event(model, evid) { + return evid in model.all_events +} + +/* model_relay_update_lok returns a map of kinds found in all events based on + * the last seen event of each kind. It also updates the model's cached value. + * If the cached value is found it returns that instead + */ +function model_relay_update_lok(model, relay) { + let last_of_kind = model.last_event_of_kind[relay]; + if (!last_of_kind) { + last_of_kind = model.last_event_of_kind[relay] + = calculate_last_of_kind(model.all_events); + } + return last_of_kind; +} + +function model_subscribe_defaults(model, relay) { + const lok = model_relay_update_lok(model, relay); + const filters = filters_new_default_since(model, lok); + filters_subscribe(filters, model.pool, [relay]); +} + +function new_model() { + return { + done_init: {}, + notifications: 0, + max_depth: 2, + all_events: {}, + reactions_to: {}, + chatrooms: {}, + unknown_ids: {}, + unknown_pks: {}, + deletions: {}, + but_wait_theres_more: 0, + cw_open: {}, + views: { + home: new_timeline('home'), + explore: { + ...new_timeline('explore'), + seen: new Set(), + }, + notifications: { + ...new_timeline('notifications'), + max_depth: 1, + }, + profile: new_timeline('profile'), + thread: new_timeline('thread'), + }, + pow: 0, // pow difficulty target + deleted: {}, + profiles: {}, + profile_events: {}, + last_event_of_kind: {}, + contacts: { + event: null, + friends: new Set(), + friend_of_friends: new Set(), + }, + } +} + +function new_timeline(name) { + return { + name, + events: [], + rendered: new Set(), + depths: {}, + expanded: new Set(), + } +} diff --git a/web/js/unknowns.js b/web/js/unknowns.js new file mode 100644 index 0000000..c2e4aa9 --- /dev/null +++ b/web/js/unknowns.js @@ -0,0 +1,107 @@ +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 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) +} + +function get_non_expired_unknowns(unks, type) +{ + const MAX_ATTEMPTS = 2 + + function sort_parent_created(a_id, b_id) { + const a = unks[a_id] + const b = unks[b_id] + return b.parent_created - a.parent_created + } + + let new_expired = 0 + const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => { + if (ids.length >= 255) + return ids + + const unk = unks[unk_id] + if (unk.attempts >= MAX_ATTEMPTS) { + if (!unk.expired) { + unk.expired = true + new_expired++ + } + return ids + } + + unk.attempts++ + + ids.push(unk_id) + return ids + }, []) + + if (new_expired !== 0) + log_debug("Gave up looking for %d %s", new_expired, type) + + return ids +} + +function fetch_unknown_events(damus) +{ + let filters = [] + 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] // TODO don't hardcode event kinds + 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 schedule_unknown_refetch(damus) +{ + const INTERVAL = 5000 + if (!damus.unknown_timer) { + log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000) + damus.unknown_timer = setTimeout(() => { + fetch_unknown_events(damus) + + setTimeout(() => { + delete damus.unknown_timer + if (damus.but_wait_theres_more > 0) { + damus.but_wait_theres_more = 0 + schedule_unknown_refetch(damus) + } + }, INTERVAL) + }, INTERVAL) + fetch_unknown_events(damus) + } else { + damus.but_wait_theres_more++ + } +} + diff --git a/web/js/util.js b/web/js/util.js index d5e8582..39c4f2b 100644 --- a/web/js/util.js +++ b/web/js/util.js @@ -8,14 +8,15 @@ const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\ function log_error(fmt, ...args) { console.error(fmt, ...args) } - function log_debug(fmt, ...args) { console.debug(fmt, ...args) } - function log_info(fmt, ...args) { console.info(fmt, ...args) } +function log_warn(fmt, ...args) { + console.warn(fmt, ...args) +} function safe_parse_json(data, message) { let value = undefined; @@ -128,34 +129,15 @@ function difficulty_to_prefix(d) { return s } -function calculate_pow(ev) { - const id_bits = leading_zero_bits(ev.id) - for (const tag of ev.tags) { - if (tag.length >= 3 && tag[0] === "nonce") { - const target = +tag[2] - if (isNaN(target)) - return 0 - - // if our nonce target is smaller than the difficulty, - // then we use the nonce target as the actual difficulty - return min(target, id_bits) - } - } - - // not a valid pow if we don't have a difficulty target - return 0 -} - -/* can_reply returns a boolean value based on if you can "reply" to the event - * in the manner of a chat. - */ function can_reply(ev) { - return ev.kind === 1 || ev.kind === 42 + log_debug("can_reply is deprecated, use event_can_reply"); + return event_can_reply(ev); } function should_add_to_timeline(ev) { // TODO rename should_add_to_timeline to is_timeline_event - return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 + log_debug("should_add_to_timeline is deprecated, use event_is_timeline"); + return event_is_timeline(ev); } /* time_delta returns a string of the time of current since previous. @@ -239,12 +221,6 @@ function get_picture(pk, profile) { 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; @@ -257,3 +233,8 @@ function debounce(f, interval) { }; } +function process_json_content(ev) { + ev.json_content = safe_parse_json(ev.content, "event json_content"); +} + + From f417732942f9693086d68c98b4fb7ac71ef67379 Mon Sep 17 00:00:00 2001 From: Thomas Mathews Date: Fri, 16 Dec 2022 15:15:33 -0800 Subject: [PATCH 2/3] Got a unified timeline working --- web/js/damus.js | 31 ++++++++++++++--- web/js/event.js | 23 ++++--------- web/js/lib.js | 10 ++++++ web/js/model.js | 34 +++++++++++++++++++ web/js/ui/fmt.js | 9 +++-- web/js/ui/render.js | 57 +++++++++++++++++++++---------- web/js/ui/state.js | 81 +++++++++++++++++++++++++++++++++++++++++++++ web/js/util.js | 30 +++++++++++------ 8 files changed, 224 insertions(+), 51 deletions(-) diff --git a/web/js/damus.js b/web/js/damus.js index 163b686..c7ce0f3 100644 --- a/web/js/damus.js +++ b/web/js/damus.js @@ -65,6 +65,7 @@ async function damus_web_init_ready() { pool.on("event", on_pool_event); pool.on("notice", on_pool_notice); pool.on("eose", on_pool_eose); + pool.on("ok", on_pool_ok); return pool } @@ -83,11 +84,12 @@ function on_pool_open(relay) { } function on_pool_notice(relay, notice) { - console.info("notice", notice); + console.info("notice", relay.url, notice); // DO NOTHING } -// TODO document what EOSE is +// on_pool_eose occurs when all storage from a relay has been sent to the +// client. async function on_pool_eose(relay, sub_id) { console.info("eose", relay.url, sub_id); const model = DAMUS; @@ -100,16 +102,22 @@ async function on_pool_eose(relay, sub_id) { case ids.profiles: //const view = get_current_view() //handle_profiles_loaded(ids, model, view, relay) + pool.unsubscribe(ids.profiles, relay); break; case ids.unknown: // TODO document why we unsub from unknowns pool.unsubscribe(ids.unknowns, relay); break; + case ids.account: + break; } } +function on_pool_ok(relay) { + console.log("OK", arguments); +} + function on_pool_event(relay, sub_id, ev) { - console.info("event", relay.url, sub_id, ev); const model = DAMUS; const { ids, pool } = model; @@ -127,6 +135,21 @@ function on_pool_event(relay, sub_id, ev) { break; } - // TODO do smart view update logic here + // Refresh view + state.invalidated.push(ev.id); + clearTimeout(state.timer); + state.timer = setTimeout(() => { + view_timeline_update(model, state); + }, 1000); + + // If it was metadata let's refresh the usernames and pics + if (ev.kind == 0) { + view_timeline_update_profiles(model, state, ev); + } } +const state = { + invalidated: [], + timer: -1, +}; + diff --git a/web/js/event.js b/web/js/event.js index cdbf564..c490864 100644 --- a/web/js/event.js +++ b/web/js/event.js @@ -77,21 +77,6 @@ function event_get_tag_refs(tags) { return {root, reply, pubkeys} } -function events_insert_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 passes_spam_filter(contacts, ev, pow) { log_warn("passes_spam_filter deprecated, use event_is_spam"); return !event_is_spam(ev, contacts, pow); @@ -103,5 +88,11 @@ function event_is_spam(ev, contacts, pow) { return ev.pow >= pow } - +function event_cmp_created(a, b) { + if (a.created_at > b.created_at) + return 1; + if (a.created_at < b.created_at) + return -1; + return 0; +} diff --git a/web/js/lib.js b/web/js/lib.js index e0e01a8..079b183 100644 --- a/web/js/lib.js +++ b/web/js/lib.js @@ -185,3 +185,13 @@ function insert_event_sorted(evs, new_ev) { log_warn("insert_event_sorted deprecated, use events_insert_sorted"); events_insert_sorted(evs, new_ev); } +function can_reply(ev) { + log_warn("can_reply is deprecated, use event_can_reply"); + return event_can_reply(ev); +} +function should_add_to_timeline(ev) { + // TODO rename should_add_to_timeline to is_timeline_event + log_warn("should_add_to_timeline is deprecated, use event_is_timeline"); + return event_is_timeline(ev); +} + diff --git a/web/js/model.js b/web/js/model.js index c10baed..e36fa24 100644 --- a/web/js/model.js +++ b/web/js/model.js @@ -198,6 +198,40 @@ function model_subscribe_defaults(model, relay) { filters_subscribe(filters, model.pool, [relay]); } +function test_model_events_arr() { + const arr = model_events_arr({all_events: { + "c": {name: "c", created_at: 2}, + "a": {name: "a", created_at: 0}, + "b": {name: "b", created_at: 1}, + "e": {name: "e", created_at: 4}, + "d": {name: "d", created_at: 3}, + }}); + let last; + while(arr.length > 0) { + let ev = arr.pop(); + log_debug("test:", ev.name, ev.created_at); + if (!last) { + last = ev; + continue; + } + if (ev.created_at > last.created_at) { + log_error(`ev ${ev.name} should be before ${last.name}`); + } + last = ev; + } +} + +function model_events_arr(model) { + const events = model.all_events; + let arr = []; + for (const evid in events) { + const ev = events[evid]; + const i = arr_bsearch_insert(arr, ev, event_cmp_created); + arr.splice(i, 0, ev); + } + return arr; +} + function new_model() { return { done_init: {}, diff --git a/web/js/ui/fmt.js b/web/js/ui/fmt.js index be92714..14101a6 100644 --- a/web/js/ui/fmt.js +++ b/web/js/ui/fmt.js @@ -1,7 +1,12 @@ function linkify(text, show_media) { return text.replace(URL_REGEX, function(match, p1, p2, p3) { - const url = p2+p3 - const parsed = new URL(url) + const url = p2+p3; + let parsed; + try { + parsed = new URL(url) + } catch (err) { + return match; + } let html; if (show_media && is_img_url(parsed.pathname)) { html = ` diff --git a/web/js/ui/render.js b/web/js/ui/render.js index b7dd500..4e71b1c 100644 --- a/web/js/ui/render.js +++ b/web/js/ui/render.js @@ -97,7 +97,7 @@ function render_unknown_event(damus, ev) { } function render_share(damus, view, ev, opts) { - //todo validate content + // TODO validate content const shared_ev = damus.all_events[ev.refs && ev.refs.root] // share isn't resolved yet. that's ok, we can render this when we have // the event @@ -113,7 +113,7 @@ function render_share(damus, view, ev, opts) { function render_comment_body(damus, ev, opts) { const can_delete = damus.pubkey === ev.pubkey; - const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, can_delete) + const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, {can_delete}) const show_media = !opts.is_composing return ` @@ -165,11 +165,9 @@ function render_event(damus, view, ev, opts={}) { view.rendered.add(ev.id) - const profile = damus.profiles[ev.pubkey] - const delta = time_delta(new Date().getTime(), ev.created_at*1000) - const has_bot_line = opts.is_reply const reply_line_bot = (has_bot_line && render_reply_line_bot()) || "" + if (opts.is_composing) has_bot_line = true; const deleted = is_deleted(damus, ev.id) if (deleted && !opts.is_reply) @@ -183,10 +181,30 @@ function render_event(damus, view, ev, opts={}) { } const has_top_line = replied_events !== "" - const border_bottom = opts.is_composing || has_bot_line ? "" : "bottom-border"; return ` ${replied_events} -
+ ` + render_event2(damus, ev, { + deleted, + has_top_line, + has_bot_line, + reply_line_bot, + }); +} + +function render_event2(model, ev, opts={}) { + let { + deleted, + has_bot_line, + has_top_line, + reply_line_bot, + } = opts + + const profile = model.profiles[ev.pubkey] + const delta = time_delta(new Date().getTime(), ev.created_at*1000) + const border_bottom = has_bot_line ? "" : "bottom-border"; + const body = deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts) + if (!reply_line_bot) reply_line_bot = ''; + return `
${render_reply_line_top(has_top_line)} ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} @@ -201,11 +219,10 @@ function render_event(damus, view, ev, opts={}) {
- ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)} + ${body}
-
- ` + ` } function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) { @@ -241,7 +258,8 @@ function render_reaction(model, reaction) { return render_pfp(reaction.pubkey, profile) } -function render_action_bar(damus, ev, can_delete) { +function render_action_bar(model, ev, opts={}) { + let { can_delete } = opts; let delete_html = "" if (can_delete) delete_html = ` @@ -249,10 +267,9 @@ function render_action_bar(damus, ev, can_delete) { ` - const groups = get_reactions(damus, ev.id) - const like = "❤️" - const likes = groups[like] || {} - const react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes) + const groups = get_reactions(model, ev.id) + const react_onclick = render_react_onclick(model.pubkey, ev.id, + "❤️", groups["❤️"] || {}) return `
-
@@ -84,7 +84,7 @@
-
+
@@ -107,31 +107,7 @@
-
- -
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
+
@@ -151,14 +127,18 @@

-
+
+
+ +
+
+
` } -function render_loading_spinner() -{ +function render_loading_spinner() { return `
diff --git a/web/js/ui/state.js b/web/js/ui/state.js index 5a6006f..50fc75f 100644 --- a/web/js/ui/state.js +++ b/web/js/ui/state.js @@ -13,25 +13,7 @@ function get_thread_max_depth(damus, view, root_id) { 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) { +/*function expand_thread(id, reply_id) { const view = get_current_view() const root_id = get_thread_root_id(DAMUS, id) if (!root_id) { @@ -41,7 +23,7 @@ function expand_thread(id, reply_id) { 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] @@ -52,71 +34,18 @@ function get_thread_root_id(damus, id) { 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 switch_view(mode, opts) { + log_warn("switch_view deprecated, use view_timeline_apply_mode"); + view_timeline_apply_mode(DAMUS, mode, opts); } -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 view_get_timeline_el() { + return find_node("#timeline"); } -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] -} - -function view_timeline_update_profiles(model, state, ev) { +function view_timeline_update_profiles(model, ev) { let xs, html; - const el = find_node("#view #home-view .events"); + const el = view_get_timeline_el(); const pk = ev.pubkey; const p = model.profiles[pk]; @@ -140,8 +69,98 @@ function view_timeline_update_profiles(model, state, ev) { } } -function view_timeline_update(model, state) { - const el = find_node("#view #home-view .events"); +function view_timeline_update_timestamps(model) { + const el = view_get_timeline_el(); + let xs = el.querySelectorAll(".timestamp"); + let now = new Date().getTime(); + for (const x of xs) { + let t = parseInt(x.dataset.timestamp) + x.innerText = fmt_since_str(now, t*1000); + } +} + +function view_timeline_update_reaction(model, ev) { + // TODO loop through elements with ev reactions to and update them +} + +const VM_FRIENDS = "friends"; +const VM_EXPLORE = "explore"; +const VM_NOTIFICATIONS = "notifications"; +const VM_THREAD = "thread"; +const VM_USER = "user"; +// friends: mine + only events that are from my contacts +// explore: all events +// notifications: reactions & replys +// thread: all events in response to target event +// user: all events by pubkey + +function view_mode_contains_event(model, ev, mode, opts={}) { + switch(mode) { + case VM_EXPLORE: + return ev.kind != KIND_REACTION; + case VM_USER: + return opts.pubkey && ev.pubkey == opts.pubkey; + case VM_FRIENDS: + return ev.pubkey == model.pubkey || contact_is_friend(model.contacts, ev.pubkey); + case VM_THREAD: + return ev.id == opts.thread_id || (ev.refs && ( + ev.refs.root == opts.thread_id || + ev.refs.reply == opts.thread_id)); + //event_refs_event(ev, opts.thread_id); + case VM_NOTIFICATIONS: + return event_refs_pubkey(ev, model.pubkey); + } + return false; +} + +function view_timeline_apply_mode(model, mode, opts={}) { + let xs; + const { pubkey, thread_id } = opts; + const el = view_get_timeline_el(); + + el.dataset.mode = mode; + switch(mode) { + case VM_THREAD: + el.dataset.threadId = thread_id; + case VM_USER: + el.dataset.pubkey = pubkey; + break; + default: + delete el.dataset.threadId; + delete el.dataset.pubkey; + break; + } + + const names = {}; + names[VM_FRIENDS] = "Home"; + names[VM_EXPLORE] = "Explore"; + names[VM_NOTIFICATIONS] = "Notifications"; + names[VM_USER] = "Profile"; + names[VM_THREAD] = "Thread"; + find_node("#view header > label").innerText = mode == VM_USER ? render_name_plain(DAMUS.profiles[opts.pubkey]) : names[mode]; + find_node("#nav > div[data-active]").dataset.active = names[mode].toLowerCase(); + find_node("#view [role='profile-info']").classList.toggle("hide", mode != VM_USER); + find_node("#newpost").classList.toggle("hide", mode != VM_FRIENDS); + + xs = el.querySelectorAll(".event"); + for (const x of xs) { + let evid = x.id.substr(2); + let ev = model.all_events[evid]; + x.classList.toggle("hide", + !view_mode_contains_event(model, ev, mode, opts)); + } +} + +/* view_timeline_update iterates through invalidated event ids and either adds + * or removes them from the timeline. + */ +function view_timeline_update(model) { + const el = view_get_timeline_el(); + const mode = el.dataset.mode; + const opts = { + thread_id: el.dataset.threadId, + pubkey: el.dataset.pubkey, + }; // for each event not rendered, go through the list and render it marking // it as rendered and adding it to the appropriate fragment. fragments are @@ -151,27 +170,31 @@ function view_timeline_update(model, state) { // const cache = {}; // Dumb function to insert needed events + let visible_count = 0; const all = model_events_arr(model); - while (state.invalidated.length > 0) { - var evid = state.invalidated.pop(); + while (model.invalidated.length > 0) { + var evid = model.invalidated.pop(); var ev = model.all_events[evid]; - if (!event_is_renderable(ev)) { + if (!event_is_renderable(ev) || model_is_event_deleted(model, evid)) { // TODO check deleted let x = find_node("#ev"+evid, el); if (x) el.removeChild(x); continue; } - // TODO if event is not viewable for page, simply hide it - // if event is in el already, do nothing or update? let ev_el = find_node("#ev"+evid, el); if (ev_el) { continue; } else { let div = document.createElement("div"); - div.innerHTML = render_event2(model, ev, {}); + div.innerHTML = render_event(model, ev, {}); ev_el = div.firstChild; + if (!view_mode_contains_event(model, ev, mode, opts)) { + ev_el.classList.add("hide"); + } else { + visible_count++; + } } // find prior event element and insert it before that @@ -187,6 +210,9 @@ function view_timeline_update(model, state) { el.insertBefore(ev_el, prior_el); } } + + if (visible_count > 0) + find_node("#view .loading-events").classList.add("hide"); } function event_is_renderable(ev={}) { diff --git a/web/js/ui/util.js b/web/js/ui/util.js index 1502c15..bb13200 100644 --- a/web/js/ui/util.js +++ b/web/js/ui/util.js @@ -172,17 +172,16 @@ async function do_send_reply() { } function reply_to(evid) { + const ev = DAMUS.all_events[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}) - + replying_to.innerHTML = render_event_nointeract(DAMUS, ev, { + is_composing: true, + nobar: true + }); + modal.classList.remove("closed") replybox.focus() } @@ -190,7 +189,7 @@ 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") + const el = document.querySelector(".my-userpic"); if (!force && el.dataset.loaded) return; el.dataset.loaded = true; el.innerHTML = html; @@ -261,4 +260,35 @@ function get_privkey() { return privkey } +function open_thread(thread_id) { + view_timeline_apply_mode(DAMUS, VM_THREAD, { thread_id }); +} +function open_profile(pubkey) { + view_timeline_apply_mode(DAMUS, VM_USER, { pubkey }); + + const profile = DAMUS.profiles[pubkey]; + const el = find_node("[role='profile-info']"); + // TODO show loading indicator then render + + find_node("[role='profile-image']", el).src = get_picture(pubkey, profile); + find_nodes("[role='profile-name']", el).forEach(el => { + el.innerText = render_name_plain(profile); + }); + + const el_nip5 = find_node("[role='profile-nip5']", el) + el_nip5.innerText = profile.nip05; + el_nip5.classList.toggle("hide", !profile.nip05); + + const el_desc = find_node("[role='profile-desc']", el) + el_desc.innerHTML = newlines_to_br(profile.about); + el_desc.classList.toggle("hide", !profile.about); + + find_node("button[role='copy-pk']", el).dataset.pk = pubkey; + + const btn_follow = find_node("button[role='follow-user']", el) + btn_follow.dataset.pk = pubkey; + // TODO check follow status + btn_follow.innerText = contact_is_friend(DAMUS.contacts, pubkey) ? "Unfollow" : "Follow"; + btn_follow.classList.toggle("hide", pubkey == DAMUS.pubkey); +} diff --git a/web/js/util.js b/web/js/util.js index e382381..0cb6665 100644 --- a/web/js/util.js +++ b/web/js/util.js @@ -22,7 +22,7 @@ function safe_parse_json(data, message) { let value = undefined; try { value = JSON.parse(data); - } catch (e) { + } catch (err) { log_error(`${message} : unable to parse JSON`, err, data); } return value; @@ -151,6 +151,11 @@ function difficulty_to_prefix(d) { /* time_delta returns a string of the time of current since previous. */ function time_delta(current, previous) { + log_warn("time_delta deprecated, use fmt_since_str"); + fmt_since_str(current, previous); +} + +function fmt_since_str(current, previous) { var msPerMinute = 60 * 1000; var msPerHour = msPerMinute * 60; var msPerDay = msPerHour * 24;