damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

commit 0b37f5b3f349601aadfbbcefb9ed462a6654f5f3
parent 954870c445649fc17d5ebfa8cab37916b9422c6a
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 14 Nov 2022 11:43:48 -0800

Explore View

Diffstat:
Mweb/index.html | 43++++++++++++++++++++++++++++++++++++-------
Mweb/js/damus.js | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mweb/js/ui/render.js | 107+++++++++++++++++++++++++++++++++++--------------------------------------------
3 files changed, 266 insertions(+), 136 deletions(-)

diff --git a/web/index.html b/web/index.html @@ -4,15 +4,15 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Damus</title> - <link rel="stylesheet" href="css/styles.css?v=116"> - <link rel="stylesheet" href="css/responsive.css?v=7"> + <link rel="stylesheet" href="css/styles.css?v=117"> + <link rel="stylesheet" href="css/responsive.css?v=8"> <link rel="stylesheet" href="css/fontawesome.css?v=2"> - <script defer src="js/ui/util.js?v=1"></script> - <script defer src="js/ui/render.js?v=1"></script> + <script defer src="js/ui/util.js?v=5"></script> + <script defer src="js/ui/render.js?v=5"></script> <script defer src="js/noble-secp256k1.js?v=1"></script> <script defer src="js/bech32.js?v=1"></script> <script defer src="js/nostr.js?v=6"></script> - <script defer src="js/damus.js?v=73"></script> + <script defer src="js/damus.js?v=74"></script> </head> <body> <script> @@ -40,15 +40,44 @@ <!--<img src="https://damus.io/img/damus.svg">--> <i class="fa-regular fa-fw fa-hand-peace"></i> </div> - <button class="nav icon" title="Home"> + <button id="home-button" onclick="switch_view('home')" class="nav icon" title="Home"> <i class="fa fa-fw fa-home"></i><span class="hide">Home</span> </button> + <button id="explore-button" onclick="switch_view('explore')" class="nav icon" title="Explore"> + <i class="fa fa-fw fa-globe"></i><span class="hide">Explore</span> + </button> <button onclick="press_logout()" title="Sign Out" class="nav icon"> <i class="fa fa-fw fa-arrow-right-from-bracket"></i><span class="hide">Sign Out</span> </button> </div> </div> - <div id="view"></div> + <div id="view"> + <div id="home-view"> + <header> + <label>Home</label> + </header> + <div id="newpost"> + <div class="my-userpic vertical-hide"><!-- To be loaded. --></div> + <div> + <textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea> + <div class="post-tools"> + <input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/> + <button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon"> + <i class="fa-solid fa-triangle-exclamation"></i> + </button> + <button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button> + </div> + </div> + </div> + <div class="events"></div> + </div> + <div style="display:none" id="explore-view"> + <header> + <label>Explore</label> + </header> + <div class="events"></div> + </div> + </div> <div class="flex-fill vertical-hide"></div> </div> <div class="modal closed" id="reply-modal"> diff --git a/web/js/damus.js b/web/js/damus.js @@ -39,19 +39,31 @@ function init_contacts() { } } +function init_timeline(name) { + return { + name, + events: [], + rendered: new Set(), + expanded: new Set(), + } +} + function init_home_model() { return { done_init: false, - loading: true, notifications: 0, - rendered: {}, all_events: {}, - expanded: new Set(), reactions_to: {}, - events: [], chatrooms: {}, deletions: {}, cw_open: {}, + views: { + home: init_timeline('home'), + explore: { + ...init_timeline('explore'), + seen: new Set(), + } + }, deleted: {}, profiles: {}, profile_events: {}, @@ -106,6 +118,7 @@ async function damus_web_init() const ids = { comments: "comments",//uuidv4(), profiles: "profiles",//uuidv4(), + explore: "explore",//uuidv4(), refevents: "refevents",//uuidv4(), account: "account",//uuidv4(), home: "home",//uuidv4(), @@ -116,7 +129,8 @@ async function damus_web_init() model.pool = pool model.view_el = document.querySelector("#view") - redraw_home_view(model) + + switch_view('home') document.addEventListener('visibilitychange', () => { update_title(model) @@ -128,7 +142,6 @@ async function damus_web_init() log_debug("relay connected", relay.url) if (!model.done_init) { - model.loading = false send_initial_filters(ids.account, model.pubkey, relay) } else { send_home_filters(ids, model, relay) @@ -142,9 +155,11 @@ async function damus_web_init() pool.on('eose', async (relay, sub_id) => { if (sub_id === ids.home) { - handle_comments_loaded(ids.profiles, model, relay) + const events = model.views.home.events + handle_comments_loaded(ids, model, events, relay) } else if (sub_id === ids.profiles) { - handle_profiles_loaded(ids.profiles, model, relay) + const view = get_current_view() + handle_profiles_loaded(ids, model, view, relay) } }) @@ -262,6 +277,8 @@ function process_event(model, ev) process_deletion_event(model, ev) else if (ev.kind === 0) process_profile_event(model, ev) + else if (ev.kind === 3) + process_contact_event(model, ev) const last_notified = get_local_state('last_notified_date') if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { @@ -287,36 +304,70 @@ function was_pubkey_notified(pubkey, ev) return false } -function should_add_to_home(ev) +function should_add_to_timeline(ev) { return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 } -let rerender_home_timer +function should_add_to_explore_timeline(view, ev) +{ + if (!should_add_to_timeline(ev)) + return false + + if (view.seen.has(ev.pubkey)) + return false + + return true +} + +function get_current_view() +{ + return DAMUS.views[DAMUS.current_view] +} + +function handle_redraw_logic(model, view_name) +{ + if (get_current_view().name === 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), 500) + } +} + function handle_home_event(ids, model, relay, sub_id, ev) { // ignore duplicates - if (model.all_events[ev.id]) - return + if (!model.all_events[ev.id]) { + model.all_events[ev.id] = ev + process_event(model, ev) + } - model.all_events[ev.id] = ev - process_event(model, ev) + ev = model.all_events[ev.id] + let is_new = false switch (sub_id) { - case ids.home: - if (should_add_to_home(ev)) - insert_event_sorted(model.events, ev) + case ids.explore: + const view = model.views.explore - if (model.realtime) { - if (rerender_home_timer) - clearTimeout(rerender_home_timer) - rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) + if (should_add_to_explore_timeline(view, ev)) { + view.seen.add(ev.pubkey) + is_new = insert_event_sorted(view.events, ev) } + + if (is_new) + handle_redraw_logic(model, 'explore') + break; + + case 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 ids.account: switch (ev.kind) { case 3: - model.loading = false - process_contact_event(model, ev) model.done_init = true model.pool.unsubscribe(ids.account, [relay]) break @@ -349,11 +400,15 @@ function send_home_filters(ids, model, relay) { const friends = contacts_friend_list(model.contacts) friends.push(model.pubkey) + const contacts_filter = {kinds: [0], authors: friends} const dms_filter = {kinds: [4], limit: 500} const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} - const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} - const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} + + const standard_kinds = [1,42,5,6,7] + + const home_filter = {kinds: standard_kinds, authors: friends, limit: 500} + const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100} let home_filters = [home_filter] let notifications_filters = [notifications_filter] @@ -422,6 +477,23 @@ 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) @@ -429,9 +501,8 @@ function process_contact_event(model, ev) { } function add_contact_if_friend(contacts, ev) { - if (!contact_is_friend(contacts, ev.pubkey)) { + if (!contact_is_friend(contacts, ev.pubkey)) return - } add_friend_contact(contacts, ev) } @@ -441,13 +512,50 @@ function contact_is_friend(contacts, pk) { } function add_friend_contact(contacts, contact) { - contacts.friends[contact.pubkey] = true + contacts.friends.add(contact.pubkey) - for (const tag of contact.tags) { - if (tag.count >= 2 && tag[0] == "p") { - contacts.friend_of_friends.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]) + } + } +} + +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.style.display = "none"; + + redraw_timeline_events(DAMUS, name) + + if (current_el) + current_el.style.display = "block"; } function load_our_relays(our_pubkey, pool, ev) { @@ -486,10 +594,10 @@ function load_our_contacts(contacts, our_pubkey, ev) { } } -function get_referenced_events(model) +function get_referenced_events(model, events) { let evset = new Set() - for (const ev of model.events) { + for (const ev of events) { for (const tag of ev.tags) { if (tag.length >= 2 && tag[0] === "e") { const e = tag[1] @@ -509,12 +617,18 @@ function fetch_referenced_events(refevents_id, model, relay) { model.pool.subscribe(refevents_id, [filter], relay) } -function handle_profiles_loaded(profiles_id, model, relay) { +function handle_profiles_loaded(ids, model, view, relay) { // stop asking for profiles - model.pool.unsubscribe(profiles_id, relay) - model.realtime = true - redraw_events(model) + model.pool.unsubscribe(ids.profiles, relay) + redraw_events(model, view) redraw_my_pfp(model) + + const fofs = Array.from(model.contacts.friend_of_friends) + let explore_filters = [ + {kinds: [1,42], authors: fofs, limit: 200}, + {kinds: [1,42], ids: ["0000"], limit: 200} + ] + model.pool.subscribe(ids.explore, explore_filters, relay) } function redraw_my_pfp(model) { @@ -547,9 +661,9 @@ function get_unknown_chatroom_ids(state) } // load profiles after comment notes are loaded -function handle_comments_loaded(profiles_id, model, relay) +function handle_comments_loaded(ids, model, events, relay) { - const pubkeys = model.events.reduce((s, ev) => { + const pubkeys = events.reduce((s, ev) => { s.add(ev.pubkey) for (const tag of ev.tags) { if (tag.length >= 2 && tag[0] === "p") { @@ -563,49 +677,46 @@ function handle_comments_loaded(profiles_id, model, relay) // load profiles and noticed chatrooms const chatroom_ids = get_unknown_chatroom_ids(model) - const profile_filter = {kinds: [0], authors: authors} + const profile_filter = {kinds: [0,3], authors: authors} const chatroom_filter = {kinds: [40], ids: chatroom_ids} let filters = [profile_filter, chatroom_filter] - const ref_evids = get_referenced_events(model) + const ref_evids = get_referenced_events(model, events) if (ref_evids.length > 0) { - log_debug("got %d new referenced events to pull after initial load", ref_evids.length) + log_debug("got %d new referenced events to pull from %s after initial load", ref_evids.length, relay.url) filters.push({ids: ref_evids}) filters.push({"#e": ref_evids}) } //console.log("subscribe", profiles_id, filter, relay) - model.pool.subscribe(profiles_id, filters, relay) + model.pool.subscribe(ids.profiles, filters, relay) } -function redraw_events(model) { - //log_debug("rendering home view") - model.rendered = {} - model.events_el.innerHTML = render_events(model) - setup_home_event_handlers(model.events_el) +function 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) + + setup_timeline_event_handlers(events_el) } -function setup_home_event_handlers(events_el) +function setup_timeline_event_handlers(events_el) { for (const el of events_el.querySelectorAll(".cw")) el.addEventListener("toggle", toggle_content_warning.bind(null)) } -function redraw_home_view(model) { - model.view_el.innerHTML = render_home_view(model) - model.events_el = document.querySelector("#events") - if (model.events.length > 0) { - redraw_events(model) +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 { - model.events_el.innerHTML= ` - <div class="loading-events"> - <span class="loader" title="Loading..."> - <i class="fa-solid fa-fw fa-spin fa-hurricane" - style="--fa-animation-duration: 0.5s;"></i> - </span> - </div> - ` + events_el.innerHTML = render_loading_spinner() } } @@ -710,14 +821,15 @@ function* yield_etags(tags) function expand_thread(id) { const ev = DAMUS.all_events[id] + const view = get_current_view() if (ev) { for (const tag of yield_etags(ev.tags)) - DAMUS.expanded.add(tag[1]) + view.expanded.add(tag[1]) } else { log_debug("expand_thread, id not found?", id) } - DAMUS.expanded.add(id) - redraw_events(DAMUS) + view.expanded.add(id) + redraw_events(DAMUS, view) } function delete_post_confirm(evid) { @@ -732,11 +844,11 @@ function delete_post_confirm(evid) { delete_post(evid, reason) } -function shouldnt_render_event(model, ev, opts) { +function shouldnt_render_event(view, ev, opts) { return !opts.is_boost_event && !opts.is_composing && - !model.expanded.has(ev.id) && - model.rendered[ev.id] + !view.expanded.has(ev.id) && + view.rendered.has(ev.id) } function press_logout() { diff --git a/web/js/ui/render.js b/web/js/ui/render.js @@ -2,42 +2,19 @@ // is done by simple string manipulations & templates. If you need to write // loops simply write it in code and return strings. -function render_home_view(model) { - return ` - <header> - <label>Home</label> - </header> - <div id="newpost"> - <div class="my-userpic vertical-hide"><!-- To be loaded. --></div> - <div> - <textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea> - <div class="post-tools"> - <input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/> - <button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon"> - <i class="fa-solid fa-triangle-exclamation"></i> - </button> - <button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button> - </div> - </div> - </div> - <div id="events"></div> - ` -} - -function render_home_event(model, ev) +function render_timeline_event(damus, view, ev) { let max_depth = 3 - if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) { + if (ev.refs && ev.refs.root && view.expanded.has(ev.refs.root)) max_depth = null - } - return render_event(model, ev, {max_depth}) + return render_event(damus, view, ev, {max_depth}) } -function render_events(model) { - return model.events +function render_events(damus, view) { + return view.events .filter((ev, i) => i < 140) - .map((ev) => render_home_event(model, ev)).join("\n") + .map((ev) => render_timeline_event(damus, view, ev)).join("\n") } function render_reply_line_top(has_top_line) { @@ -60,29 +37,29 @@ function render_thread_collapsed(model, reply_ev, opts) </div>` } -function render_replied_events(model, ev, opts) +function render_replied_events(damus, view, ev, opts) { if (!(ev.refs && ev.refs.reply)) return "" - const reply_ev = model.all_events[ev.refs.reply] + const reply_ev = damus.all_events[ev.refs.reply] if (!reply_ev) return "" opts.replies = opts.replies == null ? 1 : opts.replies + 1 - const expanded = model.expanded.has(reply_ev.id) + const expanded = view.expanded.has(reply_ev.id) if (!expanded && !(opts.max_depth == null || opts.replies < opts.max_depth)) - return render_thread_collapsed(model, reply_ev, opts) + return render_thread_collapsed(damus, reply_ev, opts) opts.is_reply = true - return render_event(model, reply_ev, opts) + return render_event(damus, view, reply_ev, opts) } -function render_replying_to_chat(model, ev) { - const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {} +function render_replying_to_chat(damus, ev) { + const chatroom = (ev.refs.root && damus.chatrooms[ev.refs.root]) || {} const roomname = chatroom.name || ev.refs.root || "??" const pks = ev.refs.pubkeys || [] - const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") + const names = pks.map(pk => render_mentioned_name(pk, damus.profiles[pk])).join(", ") const to_users = pks.length === 0 ? "" : ` to ${names}` return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` @@ -117,7 +94,7 @@ function render_unknown_event(model, ev) { return "Unknown event" } -function render_boost(model, ev, opts) { +function render_boost(damus, view, ev, opts) { //todo validate content if (!ev.json_content) return render_unknown_event(ev) @@ -125,30 +102,30 @@ function render_boost(model, ev, opts) { //const profile = model.profiles[ev.pubkey] opts.boosted = { pubkey: ev.pubkey, - profile: model.profiles[ev.pubkey] + profile: damus.profiles[ev.pubkey] } - return render_event(model, ev.json_content, opts) + return render_event(damus, view, ev.json_content, opts) } -function render_comment_body(model, ev, opts) { - const can_delete = model.pubkey === ev.pubkey; - const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev, can_delete) +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 show_media = !opts.is_composing return ` <div> - ${render_replying_to(model, ev)} - ${render_boosted_by(model, ev, opts)} + ${render_replying_to(damus, ev)} + ${render_boosted_by(ev, opts)} </div> <p> ${format_content(ev, show_media)} </p> - ${render_reactions(model, ev)} + ${render_reactions(damus, ev)} ${bar} ` } -function render_boosted_by(model, ev, opts) { +function render_boosted_by(ev, opts) { const b = opts.boosted if (!b) { return "" @@ -177,23 +154,25 @@ function render_deleted_comment_body(ev, deleted) { ` } -function render_event(model, ev, opts={}) { +function render_event(damus, view, ev, opts={}) { if (ev.kind === 6) - return render_boost(model, ev, opts) - if (shouldnt_render_event(model, ev, opts)) + return render_boost(damus, view, ev, opts) + if (shouldnt_render_event(view, ev, opts)) return "" - model.rendered[ev.id] = true - const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE + + view.rendered.add(ev.id) + + const profile = damus.profiles[ev.pubkey] || DEFAULT_PROFILE 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()) || "" - const deleted = is_deleted(model, ev.id) + const deleted = is_deleted(damus, ev.id) if (deleted && !opts.is_reply) return "" - const replied_events = render_replied_events(model, ev, opts) + const replied_events = render_replied_events(damus, view, ev, opts) let name = "" if (!deleted) { @@ -218,7 +197,7 @@ function render_event(model, ev, opts={}) { <span class="timestamp">${delta}</span> </div> <div class="comment"> - ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)} + ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)} </div> </div> </div> @@ -258,15 +237,15 @@ function render_reaction(model, reaction) { return render_pfp(reaction.pubkey, profile) } -function render_action_bar(ev, can_delete) { +function render_action_bar(damus, ev, can_delete) { let delete_html = "" if (can_delete) delete_html = `<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"><i class="fa fa-fw fa-trash"></i></a>` - const groups = get_reactions(DAMUS, ev.id) + 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 react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes) return ` <div class="action-bar"> <button class="icon" title="Reply" onclick="reply_to('${ev.id}')"><i class="fa fa-fw fa-comment"></i></a> @@ -342,4 +321,14 @@ function render_deleted_pfp() { </div>` } - +function render_loading_spinner() +{ + return ` + <div class="loading-events"> + <span class="loader" title="Loading..."> + <i class="fa-solid fa-fw fa-spin fa-hurricane" + style="--fa-animation-duration: 0.5s;"></i> + </span> + </div> + ` +}