damus.io

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

commit 4dc129236162459a5ce4c13af5d676a092bb98bc
parent f6c3fc3ff91faa36d2cd3fb5bb6d936687bd166d
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 11 Nov 2022 12:10:01 -0800

web: move webv2 to web

Diffstat:
Rwebv2/.gitignore -> web/.gitignore | 0
Mweb/Makefile | 11+++++++++++
Rwebv2/bech32.js -> web/bech32.js | 0
Mweb/damus.css | 249++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mweb/damus.js | 1293+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Rwebv2/img/damus.svg -> web/img/damus.svg | 0
Rwebv2/img/damus_notif.svg -> web/img/damus_notif.svg | 0
Rwebv2/img/favicon-notif.ico -> web/img/favicon-notif.ico | 0
Rwebv2/img/favicon.ico -> web/img/favicon.ico | 0
Mweb/index.html | 39+++++++++++++++++++++++++++++++--------
Rwebv2/noble-secp256k1.js -> web/noble-secp256k1.js | 0
Mweb/nostr.js | 197++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Dwebv2/Makefile | 15---------------
Dwebv2/damus.css | 343-------------------------------------------------------------------------------
Dwebv2/damus.js | 1373-------------------------------------------------------------------------------
Dwebv2/img/damus-nobg.svg | 186-------------------------------------------------------------------------------
Dwebv2/index.html | 51---------------------------------------------------
Dwebv2/nostr.js | 366-------------------------------------------------------------------------------
18 files changed, 1685 insertions(+), 2438 deletions(-)

diff --git a/webv2/.gitignore b/web/.gitignore diff --git a/web/Makefile b/web/Makefile @@ -1,4 +1,15 @@ +all: fake + @echo "you don't need to build anything." + +tags: fake + ctags damus.js nostr.js > $@ + +emojiregex: fake + @curl -sL 'https://raw.githubusercontent.com/mathiasbynens/emoji-test-regex-pattern/main/dist/latest/javascript.txt' dist: rsync -avzP ./ charon:/www/damus.io/web/ + + +.PHONY: fake diff --git a/webv2/bech32.js b/web/bech32.js diff --git a/web/damus.css b/web/damus.css @@ -10,11 +10,187 @@ letter-spacing: -0.05em; } + .logo img { padding-right: 18px; width: 60px; } +#content-warning-input { + width: 100%; +} + +#content-warning-input-container { + width: 100%; + margin-bottom: 10px; +} + +.deleted-comment { + border: 2px dashed white; + border-radius: 10px; + padding: 10px; +} + +.clickable { + cursor: pointer; +} + +.inline-img { + width: 100%; + border-radius: 8px; +} + +.boost { +} + +.boost-text { + padding: 0 10px 10px 10px; + color: #b6ffa8; + margin: auto; + font-size: 0.8em; +} + +.chatroom-name { + color: orange; + font-weight: bold; +} + +.line-top { + width: 2px; + height: 5px; + background-color: #eac3ff; + margin-left: auto; + margin-right: auto; +} + +.line-bot { + width: 2px; + height: 100%; + margin-top: -7px; + background-color: #eac3ff; + margin-left: auto; + margin-right: auto; +} + +body { + min-height: 100vh; + font-family: system-ui, sans; +} + +#reply-top { + display: flex; + align-items: center; +} + +#reply-modal { + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + background: rgba(0,0,0,0.4); +} + +summary { + cursor: pointer; +} + +details { + background-color: rgba(255,255,255,0.2); + padding: 10px; + margin-right: 30px; + border-radius: 20px; +} + +input { + background-color: rgba(255,255,255,0.2); + border: 0; + height: 30px; + color: white; + border-radius: 5px; +} + +textarea { + background-color: rgba(255,255,255,0.2); + border: 0; + width: 100%; + height: 40px; + color: white; + border-radius: 5px; +} + +#newpost { + margin: 0 auto 20px auto; + width: 80% +} + +::placeholder { + color: rgba(255,255,255,0.7); +} + +.post-group { + display: flex; +} + +#post-button { + width: 50px; +} + +.post-group > textarea { + margin-right: 10px; +} + +.small-txt { + font-size: 0.6em; + color: rgba(255,255,255,0.8); +} + +.reaction-group { + display: inline-flex; + background-color: rgba(255,255,255,0.15); + padding: 4px; + border-radius: 5px; +} + +.reaction-group img { + margin-left: -8px; +} + +.reaction-emoji { + margin-right: 14px; +} + +button { + border: 0; + padding: 5px; + color: white; + background-color: #9202e2; + border-radius: 5px; +} + +.close { + margin: 0 8px 0 8px; + font-size: 1.3em; + font-weight: bold; + text-decoration: none; + color: white; +} + +.small-text { + font-size: 12px; +} + +#reply-modal-content { + /*display: none; */ + padding: 10px; + margin: 10% auto; + width: 60%; + overflow: auto; /* Enable scroll if needed */ + background: linear-gradient(0deg, #A74BDB 0%, #C45BFE 100%); + border-radius: 15px; +} + html { line-height: 1.5; font-size: 20px; @@ -25,7 +201,7 @@ html { } .container { margin: 0 auto; - max-width: 36em; + max-width: 48em; hyphens: auto; word-wrap: break-word; text-rendering: optimizeLegibility; @@ -34,7 +210,6 @@ html { @media (max-width: 600px) { .container { font-size: 0.9em; - padding: 1em; } } @media print { @@ -53,10 +228,25 @@ html { } .pfp { + border-radius: 50%; +} + +.pfp-small { + width: 28px; + height: 28px; +} + +.pfp-normal { + margin: 0 15px 0 15px; width: 60px; height: 60px; - margin: 0 15px 0 15px; - border-radius: 50%; + font-size: 2.4em; +} + + +.thread-collapsed { + margin: 0 auto 5px auto; + width: 56.3%; } .comment { @@ -64,10 +254,10 @@ html { font-family: system-ui, sans; margin-bottom: 20px; flex-wrap: wrap; - align-items: center; + /*align-items: center;*/ } -.comment p { +.comment .comment-body { background-color: rgba(255.0,255.0,255.0,0.1); padding: 10px; border-radius: 8px; @@ -75,6 +265,10 @@ html { width: 55%; } +.comment-body p { + margin: 0 0 10px 0; +} + .comment .info { text-align: right; width: 18%; @@ -89,34 +283,61 @@ html { display: block; } -.comment .info span { +.comment .timestamp { font-size: 11px; color: rgba(255.0,255.0,255.0,0.7); } +.invisible { + visibility: hidden; +} + @media (max-width: 800px){ /* Reverse the order of elements in the user comments, so that the avatar and info appear after the text. */ + + .pfp { + margin: 0 0 0 0; + } + + .pfpbox { + order: 1; + margin-right: 10px; + } + .comment .info { order: 2; - width: 50%; text-align: left; + width: 70%; } - .pfp { - order: 1; - margin: 0 15px 0 0; + .comment .comment-body { + width: 100%; + order: 3; + margin: 5px 0 0 0; + } + + .line-top { + height: 20px; + } + + .line-bottom { + /*display: none;*/ } .comment { - padding: 10px; + margin: 0 0 0 0; + align-items: center; border-radius: 8px; - background-color: rgba(255.0,255.0,255.0,0.1); + /*background-color: rgba(255.0,255.0,255.0,0.1);*/ + } + + #newpost { + width: 100%; } .comment p { order: 3; - margin-top: 10px; width: 100%; } } diff --git a/web/damus.js b/web/damus.js @@ -1,4 +1,5 @@ +let DSTATE function uuidv4() { return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => @@ -24,56 +25,494 @@ function insert_event_sorted(evs, new_ev) { return true } -async function damus_web_init(thread) +function init_contacts() { + return { + event: null, + friends: new Set(), + friend_of_friends: new Set(), + } +} + +function init_home_model() { + return { + done_init: false, + loading: true, + notifications: 0, + rendered: {}, + all_events: {}, + expanded: new Set(), + reactions_to: {}, + events: [], + chatrooms: {}, + deletions: {}, + cw_open: {}, + deleted: {}, + profiles: {}, + profile_events: {}, + last_event_of_kind: {}, + contacts: init_contacts() + } +} + +const BOOTSTRAP_RELAYS = [ + "wss://relay.damus.io", + "wss://nostr-relay.wlvs.space", + "wss://nostr-pub.wellorder.net" +] + +function update_favicon(path) { - const pool = RelayPool(["wss://relay.damus.io"]) + let link = document.querySelector("link[rel~='icon']"); + const head = document.getElementsByTagName('head')[0] + + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + head.appendChild(link); + } + + link.href = path; +} + +function update_title(model) { + if (document.visibilityState === 'visible') + model.notifications = 0 + if (model.notifications === 0) { + document.title = "Damus" + update_favicon("img/damus.svg") + } else { + document.title = `(${model.notifications}) Damus` + update_favicon("img/damus_notif.svg") + } +} + +function notice_chatroom(state, id) +{ + if (!state.chatrooms[id]) + state.chatrooms[id] = {} +} + +async function damus_web_init() +{ + const model = init_home_model() + DSTATE = 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 model = {events: [], profiles: {}} - const comments_id = uuidv4() - const profiles_id = uuidv4() + + const ids = { + comments: "comments",//uuidv4(), + profiles: "profiles",//uuidv4(), + account: "account",//uuidv4(), + home: "home",//uuidv4(), + contacts: "contacts",//uuidv4(), + notifications: "notifications",//uuidv4(), + dms: "dms",//uuidv4(), + } model.pool = pool - model.el = document.querySelector("#posts") + model.view_el = document.querySelector("#view") + redraw_home_view(model) - pool.on('open', relay => { - relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) + document.addEventListener('visibilitychange', () => { + update_title(model) + }) + + pool.on('open', (relay) => { + //let authors = followers + // TODO: fetch contact list + log_debug("relay connected", relay.url) + + if (!model.done_init) { + model.loading = false + + send_initial_filters(ids.account, model.pubkey, relay) + } else { + send_home_filters(ids, model, relay) + } + //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) }); pool.on('event', (relay, sub_id, ev) => { - if (sub_id === comments_id) { - if (ev.content !== "") - insert_event_sorted(model.events, ev) - if (model.realtime) - render_home_view(model) - } else if (sub_id === profiles_id) { - try { - model.profiles[ev.pubkey] = JSON.parse(ev.content) - } catch { - console.log("failed to parse", ev.content) - } - } + handle_home_event(ids, model, relay, sub_id, ev) }) pool.on('eose', async (relay, sub_id) => { - if (sub_id === comments_id) { - handle_comments_loaded(profiles_id, model) - } else if (sub_id === profiles_id) { - handle_profiles_loaded(profiles_id, model) + if (sub_id === ids.home) { + handle_comments_loaded(ids.profiles, model, relay) + } else if (sub_id === ids.profiles) { + handle_profiles_loaded(ids.profiles, model, relay) } }) return pool } -function handle_profiles_loaded(profiles_id, model) { +function process_reaction_event(model, ev) +{ + if (!is_valid_reaction_content(ev.content)) + return + + let last = {} + + for (const tag of ev.tags) { + if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) + last[tag[0]] = tag[1] + } + + if (last.e) { + model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() + model.reactions_to[last.e].add(ev.id) + } +} + +function process_chatroom_event(model, ev) +{ + try { + model.chatrooms[ev.id] = JSON.parse(ev.content) + } catch (err) { + log_debug("error processing chatroom creation event", ev, err) + } +} + +function process_json_content(ev) +{ + try { + ev.json_content = JSON.parse(ev.content) + } catch(e) { + log_debug("error parsing json content for", ev) + } +} + +function process_deletion_event(model, ev) +{ + for (const tag of ev.tags) { + if (tag.length >= 2 && tag[0] === "e") { + const evid = tag[1] + + // we've already recorded this one as a valid deleted event + // we can just ignore it + if (model.deleted[evid]) + continue + + let ds = model.deletions[evid] = (model.deletions[evid] || new Set()) + + // add the deletion event id to the deletion set of this event + // we will use this to determine if this event is valid later in + // case we don't have the deleted event yet. + ds.add(ev.id) + } + } +} + +function is_deleted(model, evid) +{ + // we've already know it's deleted + if (model.deleted[evid]) + return model.deleted[evid] + + const ev = model.all_events[evid] + if (!ev) + return false + + // all deletion events + const ds = model.deletions[ev.id] + if (!ds) + return false + + // find valid deletion events + for (const id of ds.keys()) { + const d_ev = model.all_events[id] + if (!d_ev) + continue + + // only allow deletes from the user who created it + if (d_ev.pubkey === ev.pubkey) { + model.deleted[ev.id] = d_ev + log_debug("received deletion for", ev) + // clean up deletion data that we don't need anymore + delete model.deletions[ev.id] + return true + } else { + log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) + } + } + + return false +} + +function process_event(model, ev) +{ + ev.refs = determine_event_refs(ev.tags) + const notified = was_pubkey_notified(model.pubkey, ev) + ev.notified = notified + + if (ev.kind === 7) + process_reaction_event(model, ev) + else if (ev.kind === 42 && ev.refs && ev.refs.root) + notice_chatroom(model, ev.refs.root) + else if (ev.kind === 40) + process_chatroom_event(model, ev) + else if (ev.kind === 6) + process_json_content(ev) + else if (ev.kind === 5) + process_deletion_event(model, ev) + + const last_notified = get_local_state('last_notified_date') + if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { + set_local_state('last_notified_date', new Date().getTime()) + model.notifications++ + update_title(model) + } +} + +function was_pubkey_notified(pubkey, ev) +{ + if (!(ev.kind === 1 || ev.kind === 42)) + return false + + if (ev.pubkey === pubkey) + return false + + for (const tag of ev.tags) { + if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) + return true + } + + return false +} + +function should_add_to_home(ev) +{ + return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 +} + +let rerender_home_timer +function handle_home_event(ids, model, relay, sub_id, ev) { + model.all_events[ev.id] = ev + process_event(model, ev) + + switch (sub_id) { + case ids.home: + if (should_add_to_home(ev)) + insert_event_sorted(model.events, ev) + + if (model.realtime) { + if (rerender_home_timer) + clearTimeout(rerender_home_timer) + rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) + } + break; + case ids.account: + switch (ev.kind) { + case 3: + model.loading = false + process_contact_event(model, ev) + model.done_init = true + model.pool.unsubscribe(ids.account, [relay]) + break + case 0: + handle_profile_event(model, ev) + break + } + case ids.profiles: + try { + model.profile_events[ev.pubkey] = ev + model.profiles[ev.pubkey] = JSON.parse(ev.content) + } catch { + console.log("failed to parse", ev.content) + } + } +} + +function handle_profile_event(model, ev) { + console.log("PROFILE", ev) +} + +function send_initial_filters(account_id, pubkey, relay) { + const filter = {authors: [pubkey], kinds: [3], limit: 1} + //console.log("sending initial filter", filter) + relay.subscribe(account_id, filter) +} + +function send_home_filters(ids, model, relay) { + const friends = contacts_friend_list(model.contacts) + friends.push(model.pubkey) + + const contacts_filter = {kinds: [0], authors: friends} + const dms_filter = {kinds: [4], limit: 500} + const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} + const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} + const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} + + let home_filters = [home_filter] + let notifications_filters = [notifications_filter] + let contacts_filters = [contacts_filter] + let dms_filters = [dms_filter, our_dms_filter] + + let last_of_kind = {} + if (relay) { + last_of_kind = + model.last_event_of_kind[relay] = + model.last_event_of_kind[relay] || {} + } + + update_filters_with_since(last_of_kind, home_filters) + update_filters_with_since(last_of_kind, contacts_filters) + update_filters_with_since(last_of_kind, notifications_filters) + update_filters_with_since(last_of_kind, dms_filters) + + const subto = relay? [relay] : undefined + model.pool.subscribe(ids.home, home_filters, subto) + model.pool.subscribe(ids.contacts, contacts_filters, subto) + model.pool.subscribe(ids.notifications, notifications_filters, subto) + model.pool.subscribe(ids.dms, dms_filters, subto) +} + +function get_since_time(last_event) { + if (!last_event) { + return null + } + + return last_event.created_at - 60 * 10 +} + +function update_filter_with_since(last_of_kind, filter) { + const kinds = filter.kinds || [] + let initial = null + let earliest = kinds.reduce((earliest, kind) => { + const last = last_of_kind[kind] + let since = get_since_time(last) + + if (!earliest) { + if (since === null) + return null + + return since + } + + if (since === null) + return earliest + + return since < earliest ? since : earliest + + }, initial) + + if (earliest) + filter.since = earliest +} + +function update_filters_with_since(last_of_kind, filters) { + for (const filter of filters) { + update_filter_with_since(last_of_kind, filter) + } +} + +function contacts_friend_list(contacts) { + return Array.from(contacts.friends) +} + +function process_contact_event(model, ev) { + load_our_contacts(model.contacts, model.pubkey, ev) + load_our_relays(model.pubkey, model.pool, ev) + add_contact_if_friend(model.contacts, ev) +} + +function add_contact_if_friend(contacts, ev) { + if (!contact_is_friend(contacts, ev.pubkey)) { + return + } + + add_friend_contact(contacts, ev) +} + +function contact_is_friend(contacts, pk) { + return contacts.friends.has(pk) +} + +function add_friend_contact(contacts, contact) { + contacts.friends[contact.pubkey] = true + + for (const tag of contact.tags) { + if (tag.count >= 2 && tag[0] == "p") { + contacts.friend_of_friends.add(tag[1]) + } + } +} + +function load_our_relays(our_pubkey, pool, ev) { + if (ev.pubkey != our_pubkey) + return + + let relays + try { + relays = JSON.parse(ev.content) + } catch (e) { + log_debug("error loading relays", e) + return + } + + for (const relay of Object.keys(relays)) { + log_debug("adding relay", relay) + if (!pool.has(relay)) + pool.add(relay) + } +} + +function log_debug(fmt, ...args) { + console.log("[debug] " + fmt, ...args) +} + +function load_our_contacts(contacts, our_pubkey, ev) { + if (ev.pubkey !== our_pubkey) + return + + contacts.event = ev + + for (const tag of ev.tags) { + if (tag.length > 1 && tag[0] === "p") { + contacts.friends.add(tag[1]) + } + } +} + +function handle_profiles_loaded(profiles_id, model, relay) { // stop asking for profiles - model.pool.unsubscribe(profiles_id) + model.pool.unsubscribe(profiles_id, relay) model.realtime = true - render_home_view(model) + + redraw_events(model) +} + +function debounce(f, interval) { + let timer = null; + let first = true; + + return (...args) => { + clearTimeout(timer); + return new Promise((resolve) => { + timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); + first = false + }); + }; +} + +function get_unknown_chatroom_ids(state) +{ + let chatroom_ids = [] + for (const key of Object.keys(state.chatrooms)) { + const chatroom = state.chatrooms[key] + if (chatroom.name === undefined) + chatroom_ids.push(key) + } + return chatroom_ids } // load profiles after comment notes are loaded -function handle_comments_loaded(profiles_id, model) +function handle_comments_loaded(profiles_id, model, relay) { const pubkeys = model.events.reduce((s, ev) => { s.add(ev.pubkey) @@ -81,41 +520,704 @@ function handle_comments_loaded(profiles_id, model) }, new Set()) const authors = Array.from(pubkeys) - // load profiles - model.pool.subscribe(profiles_id, {kinds: [0], authors: authors}) + // load profiles and noticed chatrooms + const chatroom_ids = get_unknown_chatroom_ids(model) + const profile_filter = {kinds: [0], authors: authors} + const chatroom_filter = {kinds: [40], ids: chatroom_ids} + const filters = [profile_filter, chatroom_filter] + + //console.log("subscribe", profiles_id, filter, relay) + model.pool.subscribe(profiles_id, filters, relay) +} + +function redraw_events(model) { + //log_debug("rendering home view") + model.rendered = {} + model.events_el.innerHTML = render_events(model) + setup_home_event_handlers(model.events_el) +} + +function setup_home_event_handlers(events_el) +{ + for (const el of events_el.querySelectorAll(".cw")) + el.addEventListener("toggle", toggle_content_warning.bind(null)) +} + +function redraw_home_view(model) { + model.view_el.innerHTML = render_home_view(model) + model.events_el = document.querySelector("#events") + if (model.events.length > 0) + redraw_events(model) + else + model.events_el.innerText = "Loading..." +} + +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} = DSTATE + + 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 = "" +} + +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 render_home_view(model) { - model.el.innerHTML = render_events(model) + return ` + <div id="newpost"> + <div id="content-warning-input-container"> + <input id="content-warning-input" type="text" placeholder="Content Warning (nsfw, politics, etc)"></input> + </div> + + <div class="post-group"> + <textarea placeholder="What's on your mind?" id="post-input"></textarea> + <button onclick="send_post(this)" id="post-button">Post</button> + </div> + </div> + <div id="events"> + </div> + ` +} + +function render_home_event(model, ev) +{ + let max_depth = 3 + if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) { + max_depth = null + } + + return render_event(model, ev, {max_depth}) } function render_events(model) { - const render = render_event.bind(null, model) - return model.events.map(render).join("\n") + return model.events + .filter((ev, i) => i < 140) + .map((ev) => render_home_event(model, ev)).join("\n") +} + +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 render_event(model, ev) { - const profile = model.profiles[ev.pubkey] || { - name: "anon", - display_name: "Anonymous", +function determine_event_refs(tags) { + let positional_ids = [] + let pubkeys = [] + let root + let reply + let i = 0 + + for (const tag of tags) { + if (tag.length >= 4 && tag[0] == "e") { + if (tag[3] === "root") + root = tag[1] + else if (tag[3] === "reply") + reply = tag[1] + } else if (tag.length >= 2 && tag[0] == "e") { + positional_ids.push(tag[1]) + } else if (tag.length >= 2 && tag[0] == "p") { + pubkeys.push(tag[1]) + } + + i++ + } + + if (!root && !reply && positional_ids.length > 0) + return determine_event_refs_positionally(pubkeys, positional_ids) + + return {root, reply, pubkeys} +} + +function render_reply_line_top(invisible) { + const classes = invisible ? "invisible" : "" + return `<div class="line-top ${classes}"></div>` +} + +function render_reply_line_bot() { + return `<div class="line-bot"></div>` +} + +function can_reply(ev) { + return ev.kind === 1 || ev.kind === 42 +} + +const DEFAULT_PROFILE = { + name: "anon", + display_name: "Anonymous", +} + +function render_thread_collapsed(model, reply_ev, opts) +{ + if (opts.is_composing) + return "" + return `<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed clickable">...</div>` +} + +function* yield_etags(tags) +{ + for (const tag of tags) { + if (tag.length >= 2 && tag[0] === "e") + yield tag } +} + +function expand_thread(id) { + const ev = DSTATE.all_events[id] + if (ev) { + for (const tag of yield_etags(ev.tags)) + DSTATE.expanded.add(tag[1]) + } + DSTATE.expanded.add(id) + redraw_events(DSTATE) +} + +function render_replied_events(model, ev, opts) +{ + if (!(ev.refs && ev.refs.reply)) + return "" + + const reply_ev = model.all_events[ev.refs.reply] + if (!reply_ev) + return "" + + opts.replies = opts.replies == null ? 1 : opts.replies + 1 + if (!(opts.max_depth == null || opts.replies < opts.max_depth)) + return render_thread_collapsed(model, reply_ev, opts) + + opts.is_reply = true + return render_event(model, reply_ev, opts) +} + +function render_replying_to_chat(model, ev) { + const chatroom = (ev.refs.root && model.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 to_users = pks.length === 0 ? "" : ` to ${names}` + + return `<div class="replying-to small-txt">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` +} + +function render_replying_to(model, ev) { + if (!(ev.refs && ev.refs.reply)) + return "" + + if (ev.kind === 42) + return render_replying_to_chat(model, ev) + + let pubkeys = ev.refs.pubkeys || [] + if (pubkeys.length === 0 && ev.refs.reply) { + const replying_to = model.all_events[ev.refs.reply] + if (!replying_to) + return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` + + pubkeys = [replying_to.pubkey] + } + + const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") + + return ` + <span class="replying-to small-txt"> + replying to ${names} + </span> + ` +} + +function render_delete_post(model, ev) { + if (model.pubkey !== ev.pubkey) + return "" + + return ` + <span onclick="delete_post_confirm('${ev.id}')" class="clickable" style="float: right"> + ✕ + </span> + ` +} + +function delete_post_confirm(evid) { + if (!confirm("Are you sure you want to delete this post?")) + return + + const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() + + if (reason.toLowerCase() === "cancel") + return + + delete_post(evid, reason) +} + +function render_unknown_event(model, ev) { + return "Unknown event" +} + +function render_boost(model, ev, opts) { + //todo validate content + if (!ev.json_content) + return render_unknown_event(ev) + + const profile = model.profiles[ev.pubkey] + opts.is_boost_event = true + return ` + <div class="boost"> + <div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div> + ${render_event(model, ev.json_content, opts)} + </div> + ` +} + +function shouldnt_render_event(model, ev, opts) { + return !opts.is_boost_event && !opts.is_composing && !model.expanded.has(ev.id) && model.rendered[ev.id] +} + +function render_deleted_name() { + return "???" +} + +function render_deleted_pfp() { + return `<div class="pfp pfp-normal">😵</div>` +} + +function render_comment_body(model, ev, opts) { + const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev) + const show_media = !opts.is_composing + + return ` + <div> + ${render_replying_to(model, ev)} + ${render_delete_post(model, ev)} + </div> + <p> + ${format_content(ev, show_media)} + </p> + ${render_reactions(model, ev)} + ${bar} + ` +} + +function render_deleted_comment_body(ev, deleted) { + if (deleted.content) { + const show_media = false + return ` + <div class="deleted-comment"> + This comment was deleted. Reason: + <div class="quote">${format_content(deleted, show_media)}</div> + </div> + ` + } + + + return `<div class="deleted-comment">This comment was deleted</div>` +} + +function render_event(model, ev, opts={}) { + if (ev.kind === 6) + return render_boost(model, ev, opts) + if (shouldnt_render_event(model, ev, opts)) + return "" + delete opts.is_boost_event + model.rendered[ev.id] = true + const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE const delta = time_delta(new Date().getTime(), ev.created_at*1000) - const pk = ev.pubkey + + 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) + if (deleted && !opts.is_reply) + return "" + + const replied_events = render_replied_events(model, ev, opts) + return ` - <div class="comment"> + ${replied_events} + <div id="ev${ev.id}" class="comment"> <div class="info"> - ${render_name(ev.pubkey, profile)} - <span>${delta}</span> + ${deleted ? render_deleted_name() : render_name(ev.pubkey, profile)} + <span class="timestamp">${delta}</span> + </div> + <div class="pfpbox"> + ${render_reply_line_top(replied_events === "")} + ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} + ${reply_line_bot} + </div> + <div class="comment-body"> + ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)} </div> - <img class="pfp" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}"> - <p> - ${format_content(ev.content)} - </p> </div> ` } -function convert_quote_blocks(content) +function render_pfp(pk, profile, size="normal") { + const name = render_name_plain(pk, profile) + return `<img title="${name}" class="pfp pfp-${size}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">` +} + +const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)$/ + +const WORD_REGEX=/\w/ +function is_emoji(str) +{ + return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) +} + +function is_valid_reaction_content(content) +{ + return content === "+" || content === "" || is_emoji(content) +} + +function get_reaction_emoji(ev) { + if (ev.content === "+" || ev.content === "") + return "❤️" + + return ev.content +} + +function render_reaction_group(model, emoji, reactions, reacting_to) { + const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk])) + + let onclick = "" + const reaction = reactions[model.pubkey] + if (!reaction) { + onclick = `onclick="send_reply('${emoji}', '${reacting_to.id}')"` + } else { + onclick = `onclick="delete_post('${reaction.id}')"` + } + + return ` + <span ${onclick} class="reaction-group clickable"> + <span class="reaction-emoji"> + ${emoji} + </span> + ${pfps.join("\n")} + </span> + ` +} + +async function delete_post(id, reason) +{ + const ev = DSTATE.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 render_reaction(model, reaction) { + const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE + let emoji = reaction.content[0] + if (reaction.content === "+" || reaction.content === "") + emoji = "❤️" + + return render_pfp(reaction.pubkey, profile, "small") +} + +function render_reactions(model, ev) { + const reactions_set = model.reactions_to[ev.id] + 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) + } + + let str = "" + const groups = reactions.reduce((grp, r) => { + const e = get_reaction_emoji(r) + grp[e] = grp[e] || {} + grp[e][r.pubkey] = r + return grp + }, {}) + + for (const emoji of Object.keys(groups)) { + str += render_reaction_group(model, emoji, groups[emoji], ev) + } + + return ` + <div class="reactions"> + ${str} + </div> + ` +} + +function close_reply() { + const modal = document.querySelector("#reply-modal") + modal.style.display = "none"; +} + +function gather_reply_tags(pubkey, from) { + let tags = [] + for (const tag of from.tags) { + if (tag.length >= 2) { + if (tag[0] === "e") { + tags.push(tag) + } else if (tag[0] === "p" && tag[1] !== pubkey) { + tags.push(tag) + } + } + } + tags.push(["e", from.id, "", "reply"]) + if (from.pubkey !== pubkey) + tags.push(["p", from.pubkey]) + return tags +} + +async function create_deletion_event(pubkey, target, content="") +{ + const created_at = Math.floor(new Date().getTime() / 1000) + let kind = 5 + + const tags = [["e", target]] + let del = { pubkey, tags, content, created_at, kind } + + del.id = await nostrjs.calculate_id(del) + del = await sign_event(del) + return del +} + +async function create_reply(pubkey, content, from) { + const tags = gather_reply_tags(pubkey, from) + const created_at = Math.floor(new Date().getTime() / 1000) + let kind = from.kind + + // convert emoji replies into reactions + if (is_valid_reaction_content(content)) + kind = 7 + + let reply = { pubkey, tags, content, created_at, kind } + + reply.id = await nostrjs.calculate_id(reply) + reply = await sign_event(reply) + return reply +} + +function get_tag_event(tag) +{ + if (tag.length < 2) + return null + + if (tag[0] === "e") + return DSTATE.all_events[tag[1]] + + if (tag[0] === "p") + return DSTATE.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) { + DSTATE.pool.send(["EVENT", ev]) +} + +async function send_reply(content, replying_to) +{ + const ev = DSTATE.all_events[replying_to] + if (!ev) + return + + const pubkey = await get_pubkey() + let reply = await create_reply(pubkey, content, ev) + + broadcast_event(reply) + broadcast_related_events(reply) +} + +async function do_send_reply() { + const modal = document.querySelector("#reply-modal") + const replying_to = modal.querySelector("#replying-to") + + const evid = replying_to.dataset.evid + const reply_content_el = document.querySelector("#reply-content") + const content = reply_content_el.value + + await send_reply(content, evid) + + reply_content_el.value = "" + + close_reply() +} + +function bech32_decode(pubkey) { + const decoded = bech32.decode(pubkey) + const bytes = fromWords(decoded.words) + return nostrjs.hex_encode(bytes) +} + +function get_local_state(key) { + if (DSTATE[key] != null) + return DSTATE[key] + + return localStorage.getItem(key) +} + +function set_local_state(key, val) { + DSTATE[key] = val + localStorage.setItem(key, val) +} + +async function get_pubkey() { + let pubkey = get_local_state('pubkey') + + if (pubkey) + return pubkey + + if (window.nostr && window.nostr.getPublicKey) { + const pubkey = await window.nostr.getPublicKey() + console.log("got %s pubkey from nos2x", pubkey) + return pubkey + } + + pubkey = prompt("Enter pubkey (hex or npub)") + + if (!pubkey) + throw new Error("Need pubkey to continue") + + if (pubkey[0] === "n") + pubkey = bech32_decode(pubkey) + + set_local_state('pubkey', pubkey) + return 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 replying = modal.style.display === "none"; + const replying_to = modal.querySelector("#replying-to") + + replying_to.dataset.evid = evid + const ev = DSTATE.all_events[evid] + replying_to.innerHTML = render_event(DSTATE, ev, {is_composing: true, nobar: true, max_depth: 1}) + + modal.style.display = replying? "block" : "none"; +} + +function render_action_bar(ev) { + return ` + <div class="action-bar"> + <a href="javascript:reply_to('${ev.id}')">reply</a> + </div> + ` +} + +const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i +function is_img_url(path) { + return IMG_REGEX.test(path) +} + +const VID_REGEX = /(webm|mp4)$/i +function is_video_url(path) { + return VID_REGEX.test(path) +} + +const URL_REGEX = /(https?:\/\/[^\s\):]+)/g; +function linkify(text, show_media) { + return text.replace(URL_REGEX, function(url) { + const parsed = new URL(url) + if (show_media && is_img_url(parsed.pathname)) + return `<img class="inline-img" src="${url}"/>`; + else if (show_media && is_video_url(parsed.pathname)) + return ` + <video controls class="inline-img" /> + <source src="${url}"> + </video> + `; + else + return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; + }) +} + +function convert_quote_blocks(content, show_media) { const split = content.split("\n") let blockin = false @@ -125,21 +1227,66 @@ function convert_quote_blocks(content) str += "<span class='quote'>" blockin = true } - str += sanitize(line.slice(1)) + str += linkify(sanitize(line.slice(1)), show_media) } else { if (blockin) { blockin = false str += "</span>" } - str += sanitize(line) + str += linkify(sanitize(line), show_media) } return str + "<br/>" }, "") } -function format_content(content) +function get_content_warning(tags) { - return convert_quote_blocks(content) + for (const tag of tags) { + if (tag.length >= 1 && tag[0] === "content-warning") + return tag[1] || "" + } + + return null +} + +function toggle_content_warning(e) +{ + const el = e.target + const id = el.id.split("_")[1] + const ev = DSTATE.all_events[id] + + if (!ev) { + log_debug("could not find content-warning event", id) + return + } + + DSTATE.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) { + cw = cw === ""? "Content Warning" : `Content Warning: ${cw}` + const open = !!DSTATE.cw_open[ev.id]? "open" : "" + return ` + <details class="cw" id="cw_${ev.id}" ${open}> + <summary>${cw}</summary> + ${body} + </details> + ` + } + + return body } function sanitize(content) @@ -153,18 +1300,42 @@ function robohash(pk) { return "https://robohash.org/" + pk } -function get_picture(pk, profile) -{ - return sanitize(profile.picture) || robohash(pk) +function get_picture(pk, profile) { + if (profile.resolved_picture) + return profile.resolved_picture + profile.resolved_picture = sanitize(profile.picture) || robohash(pk) + return profile.resolved_picture } -function render_name(pk, profile={}) +function render_name_plain(pk, profile=DEFAULT_PROFILE) { + if (profile.sanitized_name) + return profile.sanitized_name + const display_name = profile.display_name || profile.user const username = profile.name || "anon" const name = display_name || username - return `<div class="username">${sanitize(name)}</div>` + profile.sanitized_name = sanitize(name) + return profile.sanitized_name +} + +function render_pubkey(pk) +{ + return pk.slice(-8) +} + +function render_username(pk, profile) +{ + return (profile && profile.name) || render_pubkey(pk) +} + +function render_mentioned_name(pk, profile) { + return `<span class="username">@${render_username(pk, profile)}</span>` +} + +function render_name(pk, profile) { + return `<div class="username">${render_name_plain(pk, profile)}</div>` } function time_delta(current, previous) { @@ -177,26 +1348,26 @@ function time_delta(current, previous) { var elapsed = current - previous; if (elapsed < msPerMinute) { - return Math.round(elapsed/1000) + ' seconds ago'; + return Math.round(elapsed/1000) + ' seconds ago'; } else if (elapsed < msPerHour) { - return Math.round(elapsed/msPerMinute) + ' minutes ago'; + return Math.round(elapsed/msPerMinute) + ' minutes ago'; } else if (elapsed < msPerDay ) { - return Math.round(elapsed/msPerHour ) + ' hours ago'; + return Math.round(elapsed/msPerHour ) + ' hours ago'; } else if (elapsed < msPerMonth) { - return Math.round(elapsed/msPerDay) + ' days ago'; + return Math.round(elapsed/msPerDay) + ' days ago'; } else if (elapsed < msPerYear) { - return Math.round(elapsed/msPerMonth) + ' months ago'; + return Math.round(elapsed/msPerMonth) + ' months ago'; } else { - return Math.round(elapsed/msPerYear ) + ' years ago'; + return Math.round(elapsed/msPerYear ) + ' years ago'; } } diff --git a/webv2/img/damus.svg b/web/img/damus.svg diff --git a/webv2/img/damus_notif.svg b/web/img/damus_notif.svg diff --git a/webv2/img/favicon-notif.ico b/web/img/favicon-notif.ico Binary files differ. diff --git a/webv2/img/favicon.ico b/web/img/favicon.ico Binary files differ. diff --git a/web/index.html b/web/index.html @@ -4,9 +4,9 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Damus Web</title> + <title>Damus</title> - <link rel="stylesheet" href="damus.css?v=3"> + <link rel="stylesheet" href="damus.css?v=20"> </head> <body> <section class="header"> @@ -14,14 +14,37 @@ <img src="img/damus-nobg.svg"/> </span> </section> - <div class="container"> - <div id="posts"> + <div id="view" class="container"> </div> - </div> - <script src="nostr.js?v=2"></script> - <script src="damus.js?v=3"></script> + + <div style="display: none" id="reply-modal"> + <div id="reply-modal-content"> + <span id="reply-top"> + <a class="close" href="javascript:close_reply()">✕</a> + <span class="small-text"> + Replying to... + </span> + </span> + + <div id="replying-to"> + </div> + + <div> + <textarea id="reply-content"></textarea> + </div> + + <div style="float:right"> + <button onclick="do_send_reply()" id="reply-button">Reply</button> + </div> + </div> + </div> + <script src="noble-secp256k1.js?v=1"></script> + <script src="bech32.js?v=1"></script> + <script src="nostr.js?v=6"></script> + <script src="damus.js?v=57"></script> <script> - const relay = damus_web_init("4e8b44bb43018f79bd3efcdcd71af43814cdf996e0c62adedda1ac33bf5e1371") + // I have to delay loading to wait for nos2x + const relay = setTimeout(damus_web_init, 100) </script> </body> </html> diff --git a/webv2/noble-secp256k1.js b/web/noble-secp256k1.js diff --git a/web/nostr.js b/web/nostr.js @@ -1,4 +1,4 @@ -const RelayPool = (function nostrlib() { +const nostrjs = (function nostrlib() { const WS = typeof WebSocket !== 'undefined' ? WebSocket : require('ws') function RelayPool(relays, opts) @@ -31,17 +31,18 @@ RelayPool.prototype.on = function relayPoolOn(method, fn) { RelayPool.prototype.has = function relayPoolHas(relayUrl) { for (const relay of this.relays) { - if (relay.relay === relayUrl) + if (relay.url === relayUrl) return true } return false } -RelayPool.prototype.setupHandlers = function relayPoolSetupHandlers(method, fn) +RelayPool.prototype.setupHandlers = function relayPoolSetupHandlers() { // setup its message handlers with the ones we have already - for (const handler of Object.keys(this.on)) { + const keys = Object.keys(this.onfn) + for (const handler of keys) { for (const relay of this.relays) { relay.onfn[handler] = this.onfn[handler].bind(null, relay) } @@ -64,15 +65,24 @@ RelayPool.prototype.remove = function relayPoolRemove(url) { return false } -RelayPool.prototype.subscribe = function relayPoolSubscribe(...args) { - for (const relay of this.relays) { - relay.subscribe(...args) +RelayPool.prototype.subscribe = function relayPoolSubscribe(sub_id, filters, relay_ids) { + const relays = relay_ids ? this.find_relays(relay_ids) : this.relays + for (const relay of relays) { + relay.subscribe(sub_id, filters) } } -RelayPool.prototype.unsubscribe = function relayPoolUnsubscibe(...args) { - for (const relay of this.relays) { - relay.unsubscribe(...args) +RelayPool.prototype.unsubscribe = function relayPoolUnsubscibe(sub_id, relay_ids) { + const relays = relay_ids ? this.find_relays(relay_ids) : this.relays + for (const relay of relays) { + relay.unsubscribe(sub_id) + } +} + +RelayPool.prototype.send = function relayPoolSend(payload, relay_ids) { + const relays = relay_ids ? this.find_relays(relay_ids) : this.relays + for (const relay of relays) { + relay.send(payload) } } @@ -95,6 +105,40 @@ RelayPool.prototype.add = function relayPoolAdd(relay) { return true } +RelayPool.prototype.find_relays = function relayPoolFindRelays(relay_ids) { + if (relay_ids instanceof Relay) + return [relay_ids] + + if (relay_ids.length === 0) + return [] + + if (!relay_ids[0]) + throw new Error("what!?") + + if (relay_ids[0] instanceof Relay) + return relay_ids + + return this.relays.reduce((acc, relay) => { + if (relay_ids.some((rid) => relay.url === rid)) + acc.push(relay) + return acc + }, []) +} + +Relay.prototype.wait_connected = async function relay_wait_connected(data) { + let retry = 1000 + while (true) { + if (!this.ws || this.ws.readyState !== 1) { + await sleep(retry) + retry *= 1.5 + } + else { + return + } + } +} + + function Relay(relay, opts={}) { if (!(this instanceof Relay)) @@ -109,13 +153,22 @@ function Relay(relay, opts={}) const me = this me.onfn = {} - init_websocket(me) + try { + init_websocket(me) + } catch (e) { + console.log(e) + } return this } function init_websocket(me) { - const ws = me.ws = new WS(me.url); + let ws + try { + ws = me.ws = new WS(me.url); + } catch(e) { + return null + } return new Promise((resolve, reject) => { let resolved = false ws.onmessage = (m) => { handle_nostr_message(me, m) } @@ -138,6 +191,8 @@ function init_websocket(me) { ws.onopen = () => { if (me.onfn.open) me.onfn.open() + else + console.log("no onopen???", me) if (resolved) return @@ -160,7 +215,7 @@ async function reconnect(me) await init_websocket(me) me.reconnecting = false } catch { - console.error(`error thrown during reconnect... trying again in ${n} ms`) + //console.error(`error thrown during reconnect... trying again in ${n} ms`) await sleep(n) n *= 1.5 } @@ -177,19 +232,31 @@ Relay.prototype.close = function relayClose() { } } -Relay.prototype.subscribe = function relay_subscribe(sub_id, ...filters) { - const tosend = ["REQ", sub_id, ...filters] - this.ws.send(JSON.stringify(tosend)) +Relay.prototype.subscribe = function relay_subscribe(sub_id, filters) { + if (Array.isArray(filters)) + this.send(["REQ", sub_id, ...filters]) + else + this.send(["REQ", sub_id, filters]) } Relay.prototype.unsubscribe = function relay_unsubscribe(sub_id) { - const tosend = ["CLOSE", sub_id] - this.ws.send(JSON.stringify(tosend)) + this.send(["CLOSE", sub_id]) +} + +Relay.prototype.send = async function relay_send(data) { + await this.wait_connected() + this.ws.send(JSON.stringify(data)) } function handle_nostr_message(relay, msg) { - const data = JSON.parse(msg.data) + let data + try { + data = JSON.parse(msg.data) + } catch (e) { + console.error("handle_nostr_message", msg, e) + return + } if (data.length >= 2) { switch (data[0]) { case "EVENT": @@ -204,8 +271,96 @@ function handle_nostr_message(relay, msg) } } -return RelayPool +async function sha256(message) { + if (crypto.subtle) { + const buffer = await crypto.subtle.digest('SHA-256', message); + return new Uint8Array(buffer); + } else if (require) { + const { createHash } = require('crypto'); + const hash = createHash('sha256'); + [message].forEach((m) => hash.update(m)); + return Uint8Array.from(hash.digest()); + } else { + throw new Error("The environment doesn't have sha256 function"); + } +} + +async function calculate_id(ev) { + const commit = event_commitment(ev) + const buf = new TextEncoder().encode(commit); + return hex_encode(await sha256(buf)) +} + +function event_commitment(ev) { + const {pubkey,created_at,kind,tags,content} = ev + return JSON.stringify([0, pubkey, created_at, kind, tags, content]) +} + +function hex_char(val) { + if (val < 10) + return String.fromCharCode(48 + val) + if (val < 16) + return String.fromCharCode(97 + val - 10) +} + +function hex_encode(buf) { + let str = "" + for (let i = 0; i < buf.length; i++) { + const c = buf[i] + str += hex_char(c >> 4) + str += hex_char(c & 0xF) + } + return str +} + +function char_to_hex(cstr) { + const c = cstr.charCodeAt(0) + // c >= 0 && c <= 9 + if (c >= 48 && c <= 57) { + return c - 48; + } + // c >= a && c <= f + if (c >= 97 && c <= 102) { + return c - 97 + 10; + } + // c >= A && c <= F + if (c >= 65 && c <= 70) { + return c - 65 + 10; + } + return -1; +} + + +function hex_decode(str, buflen) +{ + let bufsize = buflen || 33 + let c1, c2 + let i = 0 + let j = 0 + let buf = new Uint8Array(bufsize) + let slen = str.length + while (slen > 1) { + if (-1==(c1 = char_to_hex(str[j])) || -1==(c2 = char_to_hex(str[j+1]))) + return null; + if (!bufsize) + return null; + j += 2 + slen -= 2 + buf[i++] = (c1 << 4) | c2 + bufsize--; + } + + return buf +} + +return { + RelayPool, + calculate_id, + event_commitment, + hex_encode, + hex_decode, +} })() if (typeof module !== 'undefined' && module.exports) - module.exports = RelayPool + module.exports = nostrjs diff --git a/webv2/Makefile b/webv2/Makefile @@ -1,15 +0,0 @@ - -all: fake - @echo "you don't need to build anything." - -tags: fake - ctags damus.js nostr.js > $@ - -emojiregex: fake - @curl -sL 'https://raw.githubusercontent.com/mathiasbynens/emoji-test-regex-pattern/main/dist/latest/javascript.txt' - -dist: - rsync -avzP ./ charon:/www/damus.io/webv2/ - - -.PHONY: fake diff --git a/webv2/damus.css b/webv2/damus.css @@ -1,343 +0,0 @@ -.header { - display: flex; - margin: 30px 0 30px 0; - flex-direction: column; - align-items: center; -} - -.logo { - margin-bottom: 0; - letter-spacing: -0.05em; -} - - -.logo img { - padding-right: 18px; - width: 60px; -} - -#content-warning-input { - width: 100%; -} - -#content-warning-input-container { - width: 100%; - margin-bottom: 10px; -} - -.deleted-comment { - border: 2px dashed white; - border-radius: 10px; - padding: 10px; -} - -.clickable { - cursor: pointer; -} - -.inline-img { - width: 100%; - border-radius: 8px; -} - -.boost { -} - -.boost-text { - padding: 0 10px 10px 10px; - color: #b6ffa8; - margin: auto; - font-size: 0.8em; -} - -.chatroom-name { - color: orange; - font-weight: bold; -} - -.line-top { - width: 2px; - height: 5px; - background-color: #eac3ff; - margin-left: auto; - margin-right: auto; -} - -.line-bot { - width: 2px; - height: 100%; - margin-top: -7px; - background-color: #eac3ff; - margin-left: auto; - margin-right: auto; -} - -body { - min-height: 100vh; - font-family: system-ui, sans; -} - -#reply-top { - display: flex; - align-items: center; -} - -#reply-modal { - position: fixed; /* Stay in place */ - z-index: 1; /* Sit on top */ - left: 0; - top: 0; - width: 100%; /* Full width */ - height: 100%; /* Full height */ - background: rgba(0,0,0,0.4); -} - -summary { - cursor: pointer; -} - -details { - background-color: rgba(255,255,255,0.2); - padding: 10px; - margin-right: 30px; - border-radius: 20px; -} - -input { - background-color: rgba(255,255,255,0.2); - border: 0; - height: 30px; - color: white; - border-radius: 5px; -} - -textarea { - background-color: rgba(255,255,255,0.2); - border: 0; - width: 100%; - height: 40px; - color: white; - border-radius: 5px; -} - -#newpost { - margin: 0 auto 20px auto; - width: 80% -} - -::placeholder { - color: rgba(255,255,255,0.7); -} - -.post-group { - display: flex; -} - -#post-button { - width: 50px; -} - -.post-group > textarea { - margin-right: 10px; -} - -.small-txt { - font-size: 0.6em; - color: rgba(255,255,255,0.8); -} - -.reaction-group { - display: inline-flex; - background-color: rgba(255,255,255,0.15); - padding: 4px; - border-radius: 5px; -} - -.reaction-group img { - margin-left: -8px; -} - -.reaction-emoji { - margin-right: 14px; -} - -button { - border: 0; - padding: 5px; - color: white; - background-color: #9202e2; - border-radius: 5px; -} - -.close { - margin: 0 8px 0 8px; - font-size: 1.3em; - font-weight: bold; - text-decoration: none; - color: white; -} - -.small-text { - font-size: 12px; -} - -#reply-modal-content { - /*display: none; */ - padding: 10px; - margin: 10% auto; - width: 60%; - overflow: auto; /* Enable scroll if needed */ - background: linear-gradient(0deg, #A74BDB 0%, #C45BFE 100%); - border-radius: 15px; -} - -html { - line-height: 1.5; - font-size: 20px; - font-family: "Georgia", sans-serif; - - color: white; - background: linear-gradient(45deg, rgba(28,85,255,1) 0%, rgba(127,53,171,1) 59%, #C45BFE 100%); -} -.container { - margin: 0 auto; - max-width: 48em; - hyphens: auto; - word-wrap: break-word; - text-rendering: optimizeLegibility; - font-kerning: normal; -} -@media (max-width: 600px) { - .container { - font-size: 0.9em; - } -} -@media print { - .container { - background-color: transparent; - color: black; - font-size: 12pt; - } - p, h2, h3 { - orphans: 3; - widows: 3; - } - h2, h3, h4 { - page-break-after: avoid; - } -} - -.pfp { - border-radius: 50%; -} - -.pfp-small { - width: 28px; - height: 28px; -} - -.pfp-normal { - margin: 0 15px 0 15px; - width: 60px; - height: 60px; - font-size: 2.4em; -} - - -.thread-collapsed { - margin: 0 auto 5px auto; - width: 56.3%; -} - -.comment { - display: flex; - font-family: system-ui, sans; - margin-bottom: 20px; - flex-wrap: wrap; - /*align-items: center;*/ -} - -.comment .comment-body { - background-color: rgba(255.0,255.0,255.0,0.1); - padding: 10px; - border-radius: 8px; - margin: 0; - width: 55%; -} - -.comment-body p { - margin: 0 0 10px 0; -} - -.comment .info { - text-align: right; - width: 18%; - line-height: 0.8em; -} - -.quote { - border-left: 2px solid white; - margin-left: 10px; - padding: 10px; - background-color: rgba(255.0,255.0,255.0,0.1); - display: block; -} - -.comment .timestamp { - font-size: 11px; - color: rgba(255.0,255.0,255.0,0.7); -} - -.invisible { - visibility: hidden; -} - -@media (max-width: 800px){ - /* Reverse the order of elements in the user comments, - so that the avatar and info appear after the text. */ - - .pfp { - margin: 0 0 0 0; - } - - .pfpbox { - order: 1; - margin-right: 10px; - } - - .comment .info { - order: 2; - text-align: left; - width: 70%; - } - - .comment .comment-body { - width: 100%; - order: 3; - margin: 5px 0 0 0; - } - - .line-top { - height: 20px; - } - - .line-bottom { - /*display: none;*/ - } - - .comment { - margin: 0 0 0 0; - align-items: center; - border-radius: 8px; - /*background-color: rgba(255.0,255.0,255.0,0.1);*/ - } - - #newpost { - width: 100%; - } - - .comment p { - order: 3; - width: 100%; - } -} diff --git a/webv2/damus.js b/webv2/damus.js @@ -1,1373 +0,0 @@ - -let DSTATE - -function uuidv4() { - return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => - (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) - ); -} - -function insert_event_sorted(evs, new_ev) { - for (let i = 0; i < evs.length; i++) { - const ev = evs[i] - - if (new_ev.id === ev.id) { - return false - } - - if (new_ev.created_at > ev.created_at) { - evs.splice(i, 0, new_ev) - return true - } - } - - evs.push(new_ev) - return true -} - -function init_contacts() { - return { - event: null, - friends: new Set(), - friend_of_friends: new Set(), - } -} - -function init_home_model() { - return { - done_init: false, - loading: true, - notifications: 0, - rendered: {}, - all_events: {}, - expanded: new Set(), - reactions_to: {}, - events: [], - chatrooms: {}, - deletions: {}, - cw_open: {}, - deleted: {}, - profiles: {}, - profile_events: {}, - last_event_of_kind: {}, - contacts: init_contacts() - } -} - -const BOOTSTRAP_RELAYS = [ - "wss://relay.damus.io", - "wss://nostr-relay.wlvs.space", - "wss://nostr-pub.wellorder.net" -] - -function update_favicon(path) -{ - let link = document.querySelector("link[rel~='icon']"); - const head = document.getElementsByTagName('head')[0] - - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - head.appendChild(link); - } - - link.href = path; -} - -function update_title(model) { - if (document.visibilityState === 'visible') - model.notifications = 0 - if (model.notifications === 0) { - document.title = "Damus" - update_favicon("img/damus.svg") - } else { - document.title = `(${model.notifications}) Damus` - update_favicon("img/damus_notif.svg") - } -} - -function notice_chatroom(state, id) -{ - if (!state.chatrooms[id]) - state.chatrooms[id] = {} -} - -async function damus_web_init() -{ - const model = init_home_model() - DSTATE = 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(), - account: "account",//uuidv4(), - home: "home",//uuidv4(), - contacts: "contacts",//uuidv4(), - notifications: "notifications",//uuidv4(), - dms: "dms",//uuidv4(), - } - - model.pool = pool - model.view_el = document.querySelector("#view") - redraw_home_view(model) - - document.addEventListener('visibilitychange', () => { - update_title(model) - }) - - pool.on('open', (relay) => { - //let authors = followers - // TODO: fetch contact list - log_debug("relay connected", relay.url) - - if (!model.done_init) { - model.loading = false - - send_initial_filters(ids.account, model.pubkey, relay) - } else { - send_home_filters(ids, model, relay) - } - //relay.subscribe(comments_id, {kinds: [1,42], limit: 100}) - }); - - pool.on('event', (relay, sub_id, ev) => { - handle_home_event(ids, model, relay, sub_id, ev) - }) - - pool.on('eose', async (relay, sub_id) => { - if (sub_id === ids.home) { - handle_comments_loaded(ids.profiles, model, relay) - } else if (sub_id === ids.profiles) { - handle_profiles_loaded(ids.profiles, model, relay) - } - }) - - return pool -} - -function process_reaction_event(model, ev) -{ - if (!is_valid_reaction_content(ev.content)) - return - - let last = {} - - for (const tag of ev.tags) { - if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")) - last[tag[0]] = tag[1] - } - - if (last.e) { - model.reactions_to[last.e] = model.reactions_to[last.e] || new Set() - model.reactions_to[last.e].add(ev.id) - } -} - -function process_chatroom_event(model, ev) -{ - try { - model.chatrooms[ev.id] = JSON.parse(ev.content) - } catch (err) { - log_debug("error processing chatroom creation event", ev, err) - } -} - -function process_json_content(ev) -{ - try { - ev.json_content = JSON.parse(ev.content) - } catch(e) { - log_debug("error parsing json content for", ev) - } -} - -function process_deletion_event(model, ev) -{ - for (const tag of ev.tags) { - if (tag.length >= 2 && tag[0] === "e") { - const evid = tag[1] - - // we've already recorded this one as a valid deleted event - // we can just ignore it - if (model.deleted[evid]) - continue - - let ds = model.deletions[evid] = (model.deletions[evid] || new Set()) - - // add the deletion event id to the deletion set of this event - // we will use this to determine if this event is valid later in - // case we don't have the deleted event yet. - ds.add(ev.id) - } - } -} - -function is_deleted(model, evid) -{ - // we've already know it's deleted - if (model.deleted[evid]) - return model.deleted[evid] - - const ev = model.all_events[evid] - if (!ev) - return false - - // all deletion events - const ds = model.deletions[ev.id] - if (!ds) - return false - - // find valid deletion events - for (const id of ds.keys()) { - const d_ev = model.all_events[id] - if (!d_ev) - continue - - // only allow deletes from the user who created it - if (d_ev.pubkey === ev.pubkey) { - model.deleted[ev.id] = d_ev - log_debug("received deletion for", ev) - // clean up deletion data that we don't need anymore - delete model.deletions[ev.id] - return true - } else { - log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`) - } - } - - return false -} - -function process_event(model, ev) -{ - ev.refs = determine_event_refs(ev.tags) - const notified = was_pubkey_notified(model.pubkey, ev) - ev.notified = notified - - if (ev.kind === 7) - process_reaction_event(model, ev) - else if (ev.kind === 42 && ev.refs && ev.refs.root) - notice_chatroom(model, ev.refs.root) - else if (ev.kind === 40) - process_chatroom_event(model, ev) - else if (ev.kind === 6) - process_json_content(ev) - else if (ev.kind === 5) - process_deletion_event(model, ev) - - const last_notified = get_local_state('last_notified_date') - if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) { - set_local_state('last_notified_date', new Date().getTime()) - model.notifications++ - update_title(model) - } -} - -function was_pubkey_notified(pubkey, ev) -{ - if (!(ev.kind === 1 || ev.kind === 42)) - return false - - if (ev.pubkey === pubkey) - return false - - for (const tag of ev.tags) { - if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey) - return true - } - - return false -} - -function should_add_to_home(ev) -{ - return ev.kind === 1 || ev.kind === 42 || ev.kind === 6 -} - -let rerender_home_timer -function handle_home_event(ids, model, relay, sub_id, ev) { - model.all_events[ev.id] = ev - process_event(model, ev) - - switch (sub_id) { - case ids.home: - if (should_add_to_home(ev)) - insert_event_sorted(model.events, ev) - - if (model.realtime) { - if (rerender_home_timer) - clearTimeout(rerender_home_timer) - rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500) - } - break; - case ids.account: - switch (ev.kind) { - case 3: - model.loading = false - process_contact_event(model, ev) - model.done_init = true - model.pool.unsubscribe(ids.account, [relay]) - break - case 0: - handle_profile_event(model, ev) - break - } - case ids.profiles: - try { - model.profile_events[ev.pubkey] = ev - model.profiles[ev.pubkey] = JSON.parse(ev.content) - } catch { - console.log("failed to parse", ev.content) - } - } -} - -function handle_profile_event(model, ev) { - console.log("PROFILE", ev) -} - -function send_initial_filters(account_id, pubkey, relay) { - const filter = {authors: [pubkey], kinds: [3], limit: 1} - //console.log("sending initial filter", filter) - relay.subscribe(account_id, filter) -} - -function send_home_filters(ids, model, relay) { - const friends = contacts_friend_list(model.contacts) - friends.push(model.pubkey) - - const contacts_filter = {kinds: [0], authors: friends} - const dms_filter = {kinds: [4], limit: 500} - const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500} - const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500} - const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100} - - let home_filters = [home_filter] - let notifications_filters = [notifications_filter] - let contacts_filters = [contacts_filter] - let dms_filters = [dms_filter, our_dms_filter] - - let last_of_kind = {} - if (relay) { - last_of_kind = - model.last_event_of_kind[relay] = - model.last_event_of_kind[relay] || {} - } - - update_filters_with_since(last_of_kind, home_filters) - update_filters_with_since(last_of_kind, contacts_filters) - update_filters_with_since(last_of_kind, notifications_filters) - update_filters_with_since(last_of_kind, dms_filters) - - const subto = relay? [relay] : undefined - model.pool.subscribe(ids.home, home_filters, subto) - model.pool.subscribe(ids.contacts, contacts_filters, subto) - model.pool.subscribe(ids.notifications, notifications_filters, subto) - model.pool.subscribe(ids.dms, dms_filters, subto) -} - -function get_since_time(last_event) { - if (!last_event) { - return null - } - - return last_event.created_at - 60 * 10 -} - -function update_filter_with_since(last_of_kind, filter) { - const kinds = filter.kinds || [] - let initial = null - let earliest = kinds.reduce((earliest, kind) => { - const last = last_of_kind[kind] - let since = get_since_time(last) - - if (!earliest) { - if (since === null) - return null - - return since - } - - if (since === null) - return earliest - - return since < earliest ? since : earliest - - }, initial) - - if (earliest) - filter.since = earliest -} - -function update_filters_with_since(last_of_kind, filters) { - for (const filter of filters) { - update_filter_with_since(last_of_kind, filter) - } -} - -function contacts_friend_list(contacts) { - return Array.from(contacts.friends) -} - -function process_contact_event(model, ev) { - load_our_contacts(model.contacts, model.pubkey, ev) - load_our_relays(model.pubkey, model.pool, ev) - add_contact_if_friend(model.contacts, ev) -} - -function add_contact_if_friend(contacts, ev) { - if (!contact_is_friend(contacts, ev.pubkey)) { - return - } - - add_friend_contact(contacts, ev) -} - -function contact_is_friend(contacts, pk) { - return contacts.friends.has(pk) -} - -function add_friend_contact(contacts, contact) { - contacts.friends[contact.pubkey] = true - - for (const tag of contact.tags) { - if (tag.count >= 2 && tag[0] == "p") { - contacts.friend_of_friends.add(tag[1]) - } - } -} - -function load_our_relays(our_pubkey, pool, ev) { - if (ev.pubkey != our_pubkey) - return - - let relays - try { - relays = JSON.parse(ev.content) - } catch (e) { - log_debug("error loading relays", e) - return - } - - for (const relay of Object.keys(relays)) { - log_debug("adding relay", relay) - if (!pool.has(relay)) - pool.add(relay) - } -} - -function log_debug(fmt, ...args) { - console.log("[debug] " + fmt, ...args) -} - -function load_our_contacts(contacts, our_pubkey, ev) { - if (ev.pubkey !== our_pubkey) - return - - contacts.event = ev - - for (const tag of ev.tags) { - if (tag.length > 1 && tag[0] === "p") { - contacts.friends.add(tag[1]) - } - } -} - -function handle_profiles_loaded(profiles_id, model, relay) { - // stop asking for profiles - model.pool.unsubscribe(profiles_id, relay) - model.realtime = true - - redraw_events(model) -} - -function debounce(f, interval) { - let timer = null; - let first = true; - - return (...args) => { - clearTimeout(timer); - return new Promise((resolve) => { - timer = setTimeout(() => resolve(f(...args)), first? 0 : interval); - first = false - }); - }; -} - -function get_unknown_chatroom_ids(state) -{ - let chatroom_ids = [] - for (const key of Object.keys(state.chatrooms)) { - const chatroom = state.chatrooms[key] - if (chatroom.name === undefined) - chatroom_ids.push(key) - } - return chatroom_ids -} - -// load profiles after comment notes are loaded -function handle_comments_loaded(profiles_id, model, relay) -{ - const pubkeys = model.events.reduce((s, ev) => { - s.add(ev.pubkey) - return s - }, new Set()) - const authors = Array.from(pubkeys) - - // load profiles and noticed chatrooms - const chatroom_ids = get_unknown_chatroom_ids(model) - const profile_filter = {kinds: [0], authors: authors} - const chatroom_filter = {kinds: [40], ids: chatroom_ids} - const filters = [profile_filter, chatroom_filter] - - //console.log("subscribe", profiles_id, filter, relay) - model.pool.subscribe(profiles_id, filters, relay) -} - -function redraw_events(model) { - //log_debug("rendering home view") - model.rendered = {} - model.events_el.innerHTML = render_events(model) - setup_home_event_handlers(model.events_el) -} - -function setup_home_event_handlers(events_el) -{ - for (const el of events_el.querySelectorAll(".cw")) - el.addEventListener("toggle", toggle_content_warning.bind(null)) -} - -function redraw_home_view(model) { - model.view_el.innerHTML = render_home_view(model) - model.events_el = document.querySelector("#events") - if (model.events.length > 0) - redraw_events(model) - else - model.events_el.innerText = "Loading..." -} - -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} = DSTATE - - 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 = "" -} - -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 render_home_view(model) { - return ` - <div id="newpost"> - <div id="content-warning-input-container"> - <input id="content-warning-input" type="text" placeholder="Content Warning (nsfw, politics, etc)"></input> - </div> - - <div class="post-group"> - <textarea placeholder="What's on your mind?" id="post-input"></textarea> - <button onclick="send_post(this)" id="post-button">Post</button> - </div> - </div> - <div id="events"> - </div> - ` -} - -function render_home_event(model, ev) -{ - let max_depth = 3 - if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) { - max_depth = null - } - - return render_event(model, ev, {max_depth}) -} - -function render_events(model) { - return model.events - .filter((ev, i) => i < 140) - .map((ev) => render_home_event(model, ev)).join("\n") -} - -function determine_event_refs_positionally(pubkeys, ids) -{ - if (ids.length === 1) - return {root: ids[0], reply: ids[0], pubkeys} - else if (ids.length >= 2) - return {root: ids[0], reply: ids[1], pubkeys} - - return {pubkeys} -} - -function determine_event_refs(tags) { - let positional_ids = [] - let pubkeys = [] - let root - let reply - let i = 0 - - for (const tag of tags) { - if (tag.length >= 4 && tag[0] == "e") { - if (tag[3] === "root") - root = tag[1] - else if (tag[3] === "reply") - reply = tag[1] - } else if (tag.length >= 2 && tag[0] == "e") { - positional_ids.push(tag[1]) - } else if (tag.length >= 2 && tag[0] == "p") { - pubkeys.push(tag[1]) - } - - i++ - } - - if (!root && !reply && positional_ids.length > 0) - return determine_event_refs_positionally(pubkeys, positional_ids) - - return {root, reply, pubkeys} -} - -function render_reply_line_top(invisible) { - const classes = invisible ? "invisible" : "" - return `<div class="line-top ${classes}"></div>` -} - -function render_reply_line_bot() { - return `<div class="line-bot"></div>` -} - -function can_reply(ev) { - return ev.kind === 1 || ev.kind === 42 -} - -const DEFAULT_PROFILE = { - name: "anon", - display_name: "Anonymous", -} - -function render_thread_collapsed(model, reply_ev, opts) -{ - if (opts.is_composing) - return "" - return `<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed clickable">...</div>` -} - -function* yield_etags(tags) -{ - for (const tag of tags) { - if (tag.length >= 2 && tag[0] === "e") - yield tag - } -} - -function expand_thread(id) { - const ev = DSTATE.all_events[id] - if (ev) { - for (const tag of yield_etags(ev.tags)) - DSTATE.expanded.add(tag[1]) - } - DSTATE.expanded.add(id) - redraw_events(DSTATE) -} - -function render_replied_events(model, ev, opts) -{ - if (!(ev.refs && ev.refs.reply)) - return "" - - const reply_ev = model.all_events[ev.refs.reply] - if (!reply_ev) - return "" - - opts.replies = opts.replies == null ? 1 : opts.replies + 1 - if (!(opts.max_depth == null || opts.replies < opts.max_depth)) - return render_thread_collapsed(model, reply_ev, opts) - - opts.is_reply = true - return render_event(model, reply_ev, opts) -} - -function render_replying_to_chat(model, ev) { - const chatroom = (ev.refs.root && model.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 to_users = pks.length === 0 ? "" : ` to ${names}` - - return `<div class="replying-to small-txt">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` -} - -function render_replying_to(model, ev) { - if (!(ev.refs && ev.refs.reply)) - return "" - - if (ev.kind === 42) - return render_replying_to_chat(model, ev) - - let pubkeys = ev.refs.pubkeys || [] - if (pubkeys.length === 0 && ev.refs.reply) { - const replying_to = model.all_events[ev.refs.reply] - if (!replying_to) - return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` - - pubkeys = [replying_to.pubkey] - } - - const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") - - return ` - <span class="replying-to small-txt"> - replying to ${names} - </span> - ` -} - -function render_delete_post(model, ev) { - if (model.pubkey !== ev.pubkey) - return "" - - return ` - <span onclick="delete_post_confirm('${ev.id}')" class="clickable" style="float: right"> - ✕ - </span> - ` -} - -function delete_post_confirm(evid) { - if (!confirm("Are you sure you want to delete this post?")) - return - - const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim() - - if (reason.toLowerCase() === "cancel") - return - - delete_post(evid, reason) -} - -function render_unknown_event(model, ev) { - return "Unknown event" -} - -function render_boost(model, ev, opts) { - //todo validate content - if (!ev.json_content) - return render_unknown_event(ev) - - const profile = model.profiles[ev.pubkey] - opts.is_boost_event = true - return ` - <div class="boost"> - <div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div> - ${render_event(model, ev.json_content, opts)} - </div> - ` -} - -function shouldnt_render_event(model, ev, opts) { - return !opts.is_boost_event && !opts.is_composing && !model.expanded.has(ev.id) && model.rendered[ev.id] -} - -function render_deleted_name() { - return "???" -} - -function render_deleted_pfp() { - return `<div class="pfp pfp-normal">😵</div>` -} - -function render_comment_body(model, ev, opts) { - const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev) - const show_media = !opts.is_composing - - return ` - <div> - ${render_replying_to(model, ev)} - ${render_delete_post(model, ev)} - </div> - <p> - ${format_content(ev, show_media)} - </p> - ${render_reactions(model, ev)} - ${bar} - ` -} - -function render_deleted_comment_body(ev, deleted) { - if (deleted.content) { - const show_media = false - return ` - <div class="deleted-comment"> - This comment was deleted. Reason: - <div class="quote">${format_content(deleted, show_media)}</div> - </div> - ` - } - - - return `<div class="deleted-comment">This comment was deleted</div>` -} - -function render_event(model, ev, opts={}) { - if (ev.kind === 6) - return render_boost(model, ev, opts) - if (shouldnt_render_event(model, ev, opts)) - return "" - delete opts.is_boost_event - model.rendered[ev.id] = true - const profile = model.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) - if (deleted && !opts.is_reply) - return "" - - const replied_events = render_replied_events(model, ev, opts) - - return ` - ${replied_events} - <div id="ev${ev.id}" class="comment"> - <div class="info"> - ${deleted ? render_deleted_name() : render_name(ev.pubkey, profile)} - <span class="timestamp">${delta}</span> - </div> - <div class="pfpbox"> - ${render_reply_line_top(replied_events === "")} - ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} - ${reply_line_bot} - </div> - <div class="comment-body"> - ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)} - </div> - </div> - ` -} - -function render_pfp(pk, profile, size="normal") { - const name = render_name_plain(pk, profile) - return `<img title="${name}" class="pfp pfp-${size}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">` -} - -const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)$/ - -const WORD_REGEX=/\w/ -function is_emoji(str) -{ - return !WORD_REGEX.test(str) && REACTION_REGEX.test(str) -} - -function is_valid_reaction_content(content) -{ - return content === "+" || content === "" || is_emoji(content) -} - -function get_reaction_emoji(ev) { - if (ev.content === "+" || ev.content === "") - return "❤️" - - return ev.content -} - -function render_reaction_group(model, emoji, reactions, reacting_to) { - const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk])) - - let onclick = "" - const reaction = reactions[model.pubkey] - if (!reaction) { - onclick = `onclick="send_reply('${emoji}', '${reacting_to.id}')"` - } else { - onclick = `onclick="delete_post('${reaction.id}')"` - } - - return ` - <span ${onclick} class="reaction-group clickable"> - <span class="reaction-emoji"> - ${emoji} - </span> - ${pfps.join("\n")} - </span> - ` -} - -async function delete_post(id, reason) -{ - const ev = DSTATE.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 render_reaction(model, reaction) { - const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE - let emoji = reaction.content[0] - if (reaction.content === "+" || reaction.content === "") - emoji = "❤️" - - return render_pfp(reaction.pubkey, profile, "small") -} - -function render_reactions(model, ev) { - const reactions_set = model.reactions_to[ev.id] - 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) - } - - let str = "" - const groups = reactions.reduce((grp, r) => { - const e = get_reaction_emoji(r) - grp[e] = grp[e] || {} - grp[e][r.pubkey] = r - return grp - }, {}) - - for (const emoji of Object.keys(groups)) { - str += render_reaction_group(model, emoji, groups[emoji], ev) - } - - return ` - <div class="reactions"> - ${str} - </div> - ` -} - -function close_reply() { - const modal = document.querySelector("#reply-modal") - modal.style.display = "none"; -} - -function gather_reply_tags(pubkey, from) { - let tags = [] - for (const tag of from.tags) { - if (tag.length >= 2) { - if (tag[0] === "e") { - tags.push(tag) - } else if (tag[0] === "p" && tag[1] !== pubkey) { - tags.push(tag) - } - } - } - tags.push(["e", from.id, "", "reply"]) - if (from.pubkey !== pubkey) - tags.push(["p", from.pubkey]) - return tags -} - -async function create_deletion_event(pubkey, target, content="") -{ - const created_at = Math.floor(new Date().getTime() / 1000) - let kind = 5 - - const tags = [["e", target]] - let del = { pubkey, tags, content, created_at, kind } - - del.id = await nostrjs.calculate_id(del) - del = await sign_event(del) - return del -} - -async function create_reply(pubkey, content, from) { - const tags = gather_reply_tags(pubkey, from) - const created_at = Math.floor(new Date().getTime() / 1000) - let kind = from.kind - - // convert emoji replies into reactions - if (is_valid_reaction_content(content)) - kind = 7 - - let reply = { pubkey, tags, content, created_at, kind } - - reply.id = await nostrjs.calculate_id(reply) - reply = await sign_event(reply) - return reply -} - -function get_tag_event(tag) -{ - if (tag.length < 2) - return null - - if (tag[0] === "e") - return DSTATE.all_events[tag[1]] - - if (tag[0] === "p") - return DSTATE.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) { - DSTATE.pool.send(["EVENT", ev]) -} - -async function send_reply(content, replying_to) -{ - const ev = DSTATE.all_events[replying_to] - if (!ev) - return - - const pubkey = await get_pubkey() - let reply = await create_reply(pubkey, content, ev) - - broadcast_event(reply) - broadcast_related_events(reply) -} - -async function do_send_reply() { - const modal = document.querySelector("#reply-modal") - const replying_to = modal.querySelector("#replying-to") - - const evid = replying_to.dataset.evid - const reply_content_el = document.querySelector("#reply-content") - const content = reply_content_el.value - - await send_reply(content, evid) - - reply_content_el.value = "" - - close_reply() -} - -function bech32_decode(pubkey) { - const decoded = bech32.decode(pubkey) - const bytes = fromWords(decoded.words) - return nostrjs.hex_encode(bytes) -} - -function get_local_state(key) { - if (DSTATE[key] != null) - return DSTATE[key] - - return localStorage.getItem(key) -} - -function set_local_state(key, val) { - DSTATE[key] = val - localStorage.setItem(key, val) -} - -async function get_pubkey() { - let pubkey = get_local_state('pubkey') - - if (pubkey) - return pubkey - - if (window.nostr && window.nostr.getPublicKey) { - const pubkey = await window.nostr.getPublicKey() - console.log("got %s pubkey from nos2x", pubkey) - return pubkey - } - - pubkey = prompt("Enter pubkey (hex or npub)") - - if (!pubkey) - throw new Error("Need pubkey to continue") - - if (pubkey[0] === "n") - pubkey = bech32_decode(pubkey) - - set_local_state('pubkey', pubkey) - return 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 replying = modal.style.display === "none"; - const replying_to = modal.querySelector("#replying-to") - - replying_to.dataset.evid = evid - const ev = DSTATE.all_events[evid] - replying_to.innerHTML = render_event(DSTATE, ev, {is_composing: true, nobar: true, max_depth: 1}) - - modal.style.display = replying? "block" : "none"; -} - -function render_action_bar(ev) { - return ` - <div class="action-bar"> - <a href="javascript:reply_to('${ev.id}')">reply</a> - </div> - ` -} - -const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i -function is_img_url(path) { - return IMG_REGEX.test(path) -} - -const VID_REGEX = /(webm|mp4)$/i -function is_video_url(path) { - return VID_REGEX.test(path) -} - -const URL_REGEX = /(https?:\/\/[^\s\):]+)/g; -function linkify(text, show_media) { - return text.replace(URL_REGEX, function(url) { - const parsed = new URL(url) - if (show_media && is_img_url(parsed.pathname)) - return `<img class="inline-img" src="${url}"/>`; - else if (show_media && is_video_url(parsed.pathname)) - return ` - <video controls class="inline-img" /> - <source src="${url}"> - </video> - `; - else - return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; - }) -} - -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 += "<span class='quote'>" - blockin = true - } - str += linkify(sanitize(line.slice(1)), show_media) - } else { - if (blockin) { - blockin = false - str += "</span>" - } - str += linkify(sanitize(line), show_media) - } - return str + "<br/>" - }, "") -} - -function get_content_warning(tags) -{ - for (const tag of tags) { - if (tag.length >= 1 && tag[0] === "content-warning") - return tag[1] || "" - } - - return null -} - -function toggle_content_warning(e) -{ - const el = e.target - const id = el.id.split("_")[1] - const ev = DSTATE.all_events[id] - - if (!ev) { - log_debug("could not find content-warning event", id) - return - } - - DSTATE.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) { - cw = cw === ""? "Content Warning" : `Content Warning: ${cw}` - const open = !!DSTATE.cw_open[ev.id]? "open" : "" - return ` - <details class="cw" id="cw_${ev.id}" ${open}> - <summary>${cw}</summary> - ${body} - </details> - ` - } - - return body -} - -function sanitize(content) -{ - if (!content) - return "" - return content.replaceAll("<","&lt;").replaceAll(">","&gt;") -} - -function robohash(pk) { - return "https://robohash.org/" + pk -} - -function get_picture(pk, profile) { - if (profile.resolved_picture) - return profile.resolved_picture - profile.resolved_picture = sanitize(profile.picture) || robohash(pk) - return profile.resolved_picture -} - -function render_name_plain(pk, profile=DEFAULT_PROFILE) -{ - if (profile.sanitized_name) - return profile.sanitized_name - - const display_name = profile.display_name || profile.user - const username = profile.name || "anon" - const name = display_name || username - - profile.sanitized_name = sanitize(name) - return profile.sanitized_name -} - -function render_pubkey(pk) -{ - return pk.slice(-8) -} - -function render_username(pk, profile) -{ - return (profile && profile.name) || render_pubkey(pk) -} - -function render_mentioned_name(pk, profile) { - return `<span class="username">@${render_username(pk, profile)}</span>` -} - -function render_name(pk, profile) { - return `<div class="username">${render_name_plain(pk, profile)}</div>` -} - -function time_delta(current, previous) { - var msPerMinute = 60 * 1000; - var msPerHour = msPerMinute * 60; - var msPerDay = msPerHour * 24; - var msPerMonth = msPerDay * 30; - var msPerYear = msPerDay * 365; - - var elapsed = current - previous; - - if (elapsed < msPerMinute) { - return Math.round(elapsed/1000) + ' seconds ago'; - } - - else if (elapsed < msPerHour) { - return Math.round(elapsed/msPerMinute) + ' minutes ago'; - } - - else if (elapsed < msPerDay ) { - return Math.round(elapsed/msPerHour ) + ' hours ago'; - } - - else if (elapsed < msPerMonth) { - return Math.round(elapsed/msPerDay) + ' days ago'; - } - - else if (elapsed < msPerYear) { - return Math.round(elapsed/msPerMonth) + ' months ago'; - } - - else { - return Math.round(elapsed/msPerYear ) + ' years ago'; - } -} diff --git a/webv2/img/damus-nobg.svg b/webv2/img/damus-nobg.svg @@ -1,186 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - width="146.15311mm" - height="184.664mm" - viewBox="0 0 146.15311 184.66401" - version="1.1" - id="svg5" - inkscape:version="1.2-alpha (0bd5040e, 2022-02-05)" - sodipodi:docname="damus-nobg.svg" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:xlink="http://www.w3.org/1999/xlink" - xmlns="http://www.w3.org/2000/svg" - xmlns:svg="http://www.w3.org/2000/svg"> - <sodipodi:namedview - id="namedview7" - pagecolor="#ffffff" - bordercolor="#000000" - borderopacity="0.25" - inkscape:pageshadow="2" - inkscape:pageopacity="0.0" - inkscape:pagecheckerboard="0" - inkscape:blackoutopacity="0.0" - inkscape:document-units="mm" - showgrid="false" - inkscape:zoom="0.5946522" - inkscape:cx="73.992831" - inkscape:cy="206.8436" - inkscape:window-width="1435" - inkscape:window-height="844" - inkscape:window-x="0" - inkscape:window-y="25" - inkscape:window-maximized="0" - inkscape:current-layer="layer2" /> - <defs - id="defs2"> - <linearGradient - inkscape:collect="always" - id="linearGradient39361"> - <stop - style="stop-color:#0de8ff;stop-opacity:0.78082192;" - offset="0" - id="stop39357" /> - <stop - style="stop-color:#d600fc;stop-opacity:0.95433789;" - offset="1" - id="stop39359" /> - </linearGradient> - <inkscape:path-effect - effect="bspline" - id="path-effect255" - is_visible="true" - lpeversion="1" - weight="33.333333" - steps="2" - helper_size="0" - apply_no_weight="true" - apply_with_weight="true" - only_selected="false" /> - <linearGradient - inkscape:collect="always" - id="linearGradient2119"> - <stop - style="stop-color:#1c55ff;stop-opacity:1;" - offset="0" - id="stop2115" /> - <stop - style="stop-color:#7f35ab;stop-opacity:1;" - offset="0.5" - id="stop2123" /> - <stop - style="stop-color:#ff0bd6;stop-opacity:1;" - offset="1" - id="stop2117" /> - </linearGradient> - <linearGradient - inkscape:collect="always" - xlink:href="#linearGradient2119" - id="linearGradient2121" - x1="10.067794" - y1="248.81357" - x2="246.56145" - y2="7.1864405" - gradientUnits="userSpaceOnUse" /> - <linearGradient - inkscape:collect="always" - xlink:href="#linearGradient39361" - id="linearGradient39367" - x1="62.104473" - y1="128.78963" - x2="208.25758" - y2="128.78963" - gradientUnits="userSpaceOnUse" /> - </defs> - <g - inkscape:label="Background" - inkscape:groupmode="layer" - id="layer1" - sodipodi:insensitive="true" - style="display:none" - transform="translate(-62.104473,-36.457485)"> - <rect - style="fill:url(#linearGradient2121);fill-opacity:1;stroke-width:0.264583" - id="rect61" - width="256" - height="256" - x="-5.3875166e-08" - y="-1.0775033e-07" - ry="0" - inkscape:label="Gradient" - sodipodi:insensitive="true" /> - </g> - <g - inkscape:groupmode="layer" - id="layer2" - inkscape:label="Logo" - sodipodi:insensitive="true" - transform="translate(-62.104473,-36.457485)"> - <path - style="fill:url(#linearGradient39367);fill-opacity:1;stroke:#ffffff;stroke-width:10;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="M 101.1429,213.87373 C 67.104473,239.1681 67.104473,42.67112 67.104473,42.67112 135.18122,57.58146 203.25844,72.491904 203.25758,105.24181 c -8.6e-4,32.74991 -68.07625,83.33755 -102.11468,108.63192 z" - id="path253" - sodipodi:insensitive="true" /> - </g> - <g - inkscape:groupmode="layer" - id="layer3" - inkscape:label="Poly" - sodipodi:insensitive="true" - transform="translate(-62.104473,-36.457485)"> - <path - style="fill:#ffffff;fill-opacity:0.325424;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="M 67.32839,76.766948 112.00424,99.41949 100.04873,52.226693 Z" - id="path4648" /> - <path - style="fill:#ffffff;fill-opacity:0.274576;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="M 111.45696,98.998695 107.00758,142.60261 70.077729,105.67276 Z" - id="path9299" /> - <path - style="fill:#ffffff;fill-opacity:0.379661;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="m 111.01202,99.221164 29.14343,-37.15232 25.80641,39.377006 z" - id="path9301" /> - <path - style="fill:#ffffff;fill-opacity:0.447458;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="m 111.45696,99.443631 57.17452,55.172309 -2.89209,-53.17009 z" - id="path9368" /> - <path - style="fill:#ffffff;fill-opacity:0.20678;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="m 106.78511,142.38015 62.06884,12.68073 -57.17452,-55.617249 z" - id="path9370" /> - <path - style="fill:#ffffff;fill-opacity:0.244068;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="m 106.78511,142.38015 -28.47603,32.9254 62.51378,7.56395 z" - id="path9372" /> - <path - style="fill:#ffffff;fill-opacity:0.216949;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - d="M 165.96186,101.44585 195.7727,125.02756 182.64703,78.754017 Z" - id="path9374" /> - </g> - <g - inkscape:groupmode="layer" - id="layer4" - inkscape:label="Vertices" - transform="translate(-62.104473,-36.457485)"> - <circle - style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="path27764" - cx="106.86934" - cy="142.38014" - r="2.0022209" /> - <circle - style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="circle28773" - cx="111.54119" - cy="99.221161" - r="2.0022209" /> - <circle - style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="circle29091" - cx="165.90784" - cy="101.36163" - r="2.0022209" /> - </g> -</svg> diff --git a/webv2/index.html b/webv2/index.html @@ -1,51 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - - <title>Damus</title> - - <link rel="stylesheet" href="damus.css?v=20"> - </head> - <body> - <section class="header"> - <span class="logo"> - <img src="img/damus-nobg.svg"/> - </span> - </section> - <div id="view" class="container"> - </div> - - <div style="display: none" id="reply-modal"> - <div id="reply-modal-content"> - <span id="reply-top"> - <a class="close" href="javascript:close_reply()">✕</a> - <span class="small-text"> - Replying to... - </span> - </span> - - <div id="replying-to"> - </div> - - <div> - <textarea id="reply-content"></textarea> - </div> - - <div style="float:right"> - <button onclick="do_send_reply()" id="reply-button">Reply</button> - </div> - </div> - </div> - <script src="noble-secp256k1.js?v=1"></script> - <script src="bech32.js?v=1"></script> - <script src="nostr.js?v=6"></script> - <script src="damus.js?v=57"></script> - <script> - // I have to delay loading to wait for nos2x - const relay = setTimeout(damus_web_init, 100) - </script> - </body> -</html> - diff --git a/webv2/nostr.js b/webv2/nostr.js @@ -1,366 +0,0 @@ -const nostrjs = (function nostrlib() { -const WS = typeof WebSocket !== 'undefined' ? WebSocket : require('ws') - -function RelayPool(relays, opts) -{ - if (!(this instanceof RelayPool)) - return new RelayPool(relays) - - this.onfn = {} - this.relays = [] - - for (const relay of relays) { - this.add(relay) - } - - return this -} - -RelayPool.prototype.close = function relayPoolClose() { - for (const relay of this.relays) { - relay.close() - } -} - -RelayPool.prototype.on = function relayPoolOn(method, fn) { - for (const relay of this.relays) { - this.onfn[method] = fn - relay.onfn[method] = fn.bind(null, relay) - } -} - -RelayPool.prototype.has = function relayPoolHas(relayUrl) { - for (const relay of this.relays) { - if (relay.url === relayUrl) - return true - } - - return false -} - -RelayPool.prototype.setupHandlers = function relayPoolSetupHandlers() -{ - // setup its message handlers with the ones we have already - const keys = Object.keys(this.onfn) - for (const handler of keys) { - for (const relay of this.relays) { - relay.onfn[handler] = this.onfn[handler].bind(null, relay) - } - } -} - -RelayPool.prototype.remove = function relayPoolRemove(url) { - let i = 0 - - for (const relay of this.relays) { - if (relay.url === url) { - relay.ws && relay.ws.close() - this.relays = this.replays.splice(i, 1) - return true - } - - i += 1 - } - - return false -} - -RelayPool.prototype.subscribe = function relayPoolSubscribe(sub_id, filters, relay_ids) { - const relays = relay_ids ? this.find_relays(relay_ids) : this.relays - for (const relay of relays) { - relay.subscribe(sub_id, filters) - } -} - -RelayPool.prototype.unsubscribe = function relayPoolUnsubscibe(sub_id, relay_ids) { - const relays = relay_ids ? this.find_relays(relay_ids) : this.relays - for (const relay of relays) { - relay.unsubscribe(sub_id) - } -} - -RelayPool.prototype.send = function relayPoolSend(payload, relay_ids) { - const relays = relay_ids ? this.find_relays(relay_ids) : this.relays - for (const relay of relays) { - relay.send(payload) - } -} - -RelayPool.prototype.add = function relayPoolAdd(relay) { - if (relay instanceof Relay) { - if (this.has(relay.url)) - return false - - this.relays.push(relay) - this.setupHandlers() - return true - } - - if (this.has(relay)) - return false - - const r = Relay(relay, this.opts) - this.relays.push(r) - this.setupHandlers() - return true -} - -RelayPool.prototype.find_relays = function relayPoolFindRelays(relay_ids) { - if (relay_ids instanceof Relay) - return [relay_ids] - - if (relay_ids.length === 0) - return [] - - if (!relay_ids[0]) - throw new Error("what!?") - - if (relay_ids[0] instanceof Relay) - return relay_ids - - return this.relays.reduce((acc, relay) => { - if (relay_ids.some((rid) => relay.url === rid)) - acc.push(relay) - return acc - }, []) -} - -Relay.prototype.wait_connected = async function relay_wait_connected(data) { - let retry = 1000 - while (true) { - if (!this.ws || this.ws.readyState !== 1) { - await sleep(retry) - retry *= 1.5 - } - else { - return - } - } -} - - -function Relay(relay, opts={}) -{ - if (!(this instanceof Relay)) - return new Relay(relay, opts) - - this.url = relay - this.opts = opts - - if (opts.reconnect == null) - opts.reconnect = true - - const me = this - me.onfn = {} - - try { - init_websocket(me) - } catch (e) { - console.log(e) - } - - return this -} - -function init_websocket(me) { - let ws - try { - ws = me.ws = new WS(me.url); - } catch(e) { - return null - } - return new Promise((resolve, reject) => { - let resolved = false - ws.onmessage = (m) => { handle_nostr_message(me, m) } - ws.onclose = () => { - if (me.onfn.close) - me.onfn.close() - if (me.reconnecting) - return reject(new Error("close during reconnect")) - if (!me.manualClose && me.opts.reconnect) - reconnect(me) - } - ws.onerror = () => { - if (me.onfn.error) - me.onfn.error() - if (me.reconnecting) - return reject(new Error("error during reconnect")) - if (me.opts.reconnect) - reconnect(me) - } - ws.onopen = () => { - if (me.onfn.open) - me.onfn.open() - else - console.log("no onopen???", me) - - if (resolved) return - - resolved = true - resolve(me) - } - }); -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function reconnect(me) -{ - const reconnecting = true - let n = 100 - try { - me.reconnecting = true - await init_websocket(me) - me.reconnecting = false - } catch { - //console.error(`error thrown during reconnect... trying again in ${n} ms`) - await sleep(n) - n *= 1.5 - } -} - -Relay.prototype.on = function relayOn(method, fn) { - this.onfn[method] = fn -} - -Relay.prototype.close = function relayClose() { - if (this.ws) { - this.manualClose = true - this.ws.close() - } -} - -Relay.prototype.subscribe = function relay_subscribe(sub_id, filters) { - if (Array.isArray(filters)) - this.send(["REQ", sub_id, ...filters]) - else - this.send(["REQ", sub_id, filters]) -} - -Relay.prototype.unsubscribe = function relay_unsubscribe(sub_id) { - this.send(["CLOSE", sub_id]) -} - -Relay.prototype.send = async function relay_send(data) { - await this.wait_connected() - this.ws.send(JSON.stringify(data)) -} - -function handle_nostr_message(relay, msg) -{ - let data - try { - data = JSON.parse(msg.data) - } catch (e) { - console.error("handle_nostr_message", msg, e) - return - } - if (data.length >= 2) { - switch (data[0]) { - case "EVENT": - if (data.length < 3) - return - return relay.onfn.event && relay.onfn.event(data[1], data[2]) - case "EOSE": - return relay.onfn.eose && relay.onfn.eose(data[1]) - case "NOTICE": - return relay.onfn.notice && relay.onfn.notice(...data.slice(1)) - } - } -} - -async function sha256(message) { - if (crypto.subtle) { - const buffer = await crypto.subtle.digest('SHA-256', message); - return new Uint8Array(buffer); - } else if (require) { - const { createHash } = require('crypto'); - const hash = createHash('sha256'); - [message].forEach((m) => hash.update(m)); - return Uint8Array.from(hash.digest()); - } else { - throw new Error("The environment doesn't have sha256 function"); - } -} - -async function calculate_id(ev) { - const commit = event_commitment(ev) - const buf = new TextEncoder().encode(commit); - return hex_encode(await sha256(buf)) -} - -function event_commitment(ev) { - const {pubkey,created_at,kind,tags,content} = ev - return JSON.stringify([0, pubkey, created_at, kind, tags, content]) -} - -function hex_char(val) { - if (val < 10) - return String.fromCharCode(48 + val) - if (val < 16) - return String.fromCharCode(97 + val - 10) -} - -function hex_encode(buf) { - let str = "" - for (let i = 0; i < buf.length; i++) { - const c = buf[i] - str += hex_char(c >> 4) - str += hex_char(c & 0xF) - } - return str -} - -function char_to_hex(cstr) { - const c = cstr.charCodeAt(0) - // c >= 0 && c <= 9 - if (c >= 48 && c <= 57) { - return c - 48; - } - // c >= a && c <= f - if (c >= 97 && c <= 102) { - return c - 97 + 10; - } - // c >= A && c <= F - if (c >= 65 && c <= 70) { - return c - 65 + 10; - } - return -1; -} - - -function hex_decode(str, buflen) -{ - let bufsize = buflen || 33 - let c1, c2 - let i = 0 - let j = 0 - let buf = new Uint8Array(bufsize) - let slen = str.length - while (slen > 1) { - if (-1==(c1 = char_to_hex(str[j])) || -1==(c2 = char_to_hex(str[j+1]))) - return null; - if (!bufsize) - return null; - j += 2 - slen -= 2 - buf[i++] = (c1 << 4) | c2 - bufsize--; - } - - return buf -} - -return { - RelayPool, - calculate_id, - event_commitment, - hex_encode, - hex_decode, -} -})() - -if (typeof module !== 'undefined' && module.exports) - module.exports = nostrjs