From 077bf49fdbebacaa7714124c97b931493e7a29c5 Mon Sep 17 00:00:00 2001 From: Thomas Mathews Date: Thu, 5 Jan 2023 10:36:04 -0800 Subject: [PATCH] New Feature: Direct Messages This feature involved a lot of refactoring in order to get working correctly. I wanted to continue using the timeline view for chats thus I used alternative styling & structure for DM event kinds. This worked create since the elements map does not care. There is some queing that has to be done to decrypt message content thus I allow viewing messages even if they haven't been decrypted yet. I think this is good for transparency since you understand what is and is not decrypted. I do think that the UX could improve, because even tho it is fast, it's flashes on new messages. I did not implement saving of latest messages. I will do this later, but this feature is big enough to merge as is: an alpha state that works. I further abstracted profile & name updating to work in a more global manner. Additionally I rewrote code that had attribute scripts to use addEventListener instead. This is needed to happen anyways for security and made the codebase easier to manage. --- TODO | 13 +++ css/styles.css | 106 ++++++++++++++--- css/vars.css | 23 ++-- icon/messages-active.svg | 1 + icon/messages.svg | 1 + index.html | 74 +++++++----- js/core.js | 21 +--- js/event.js | 13 +-- js/main.js | 23 +++- js/model.js | 81 ++++++++++++- js/ui/dm.js | 81 +++++++++++++ js/ui/fmt.js | 26 ++++- js/ui/profile.js | 115 +++++++++++++++++++ js/ui/render.js | 67 +++++++++-- js/ui/state.js | 237 ++++++++++++++++++++++++++++++++------- js/ui/util.js | 155 ++----------------------- js/util.js | 53 ++++++++- 17 files changed, 798 insertions(+), 292 deletions(-) create mode 100644 TODO create mode 100644 icon/messages-active.svg create mode 100644 icon/messages.svg create mode 100644 js/ui/dm.js create mode 100644 js/ui/profile.js diff --git a/TODO b/TODO new file mode 100644 index 0000000..8ae0188 --- /dev/null +++ b/TODO @@ -0,0 +1,13 @@ +Here lie goals: + [ ] Fix UI/X issues on mobile (modal, reply, etc.) + [ ] Direct messaging + [ ] Store profiles of friends, friends of friends, & the user + [ ] Export/import contacts list + [ ] Render lighting invoices (BOLT11) Do this without a library + [ ] Render tagged users e.g. #[0] + [ ] Autocomplete for usernames in composition + [ ] Redesign embeds + [ ] Store latest 500 STANDARD_KINDS & their profiles kick out as needed + [ ] Display contacts list + [ ] Replace local storage usage + diff --git a/css/styles.css b/css/styles.css index 50d55d2..3b78440 100644 --- a/css/styles.css +++ b/css/styles.css @@ -101,13 +101,15 @@ th, td { #nav > div[data-active="home"] [role="home"] img.inactive, #nav > div[data-active="explore"] [role="explore"] img.inactive, #nav > div[data-active="notifications"] [role="notifications"] img.inactive, -#nav > div[data-active="settings"] [role="settings"] img.inactive { +#nav > div[data-active="settings"] [role="settings"] img.inactive, +#nav > div[data-active="messages"] [role="dm"] img.inactive { display: none; } #nav > div[data-active="home"] [role="home"] img.active, #nav > div[data-active="explore"] [role="explore"] img.active, #nav > div[data-active="notifications"] [role="notifications"] img.active, -#nav > div[data-active="settings"] [role="settings"] img.active { +#nav > div[data-active="settings"] [role="settings"] img.active, +#nav > div[data-active="messages"] [role="dm"] img.active { display: block; } #app-icon-logo > img { @@ -148,9 +150,12 @@ button.nav > img.icon { padding: 15px; } #gnav.open button[role="home"] { - top: -300px; + top: -375px; } #gnav.open button[role="explore"] { + top: -300px; +} +#gnav.open button[role="dm"] { top: -225px; } #gnav.open button[role="notifications"] { @@ -168,7 +173,7 @@ button.nav > img.icon { top: 10px; right: 13px; border-radius: 13px; - background: #20ff00; + background: var(--clrNotification); color: white; font-weight: 800; width: 5px; @@ -199,6 +204,13 @@ button.nav > img.icon { font-weight: 800; display: block; } +#view > div > header > .pfp { + width: 32px; + height: 32px; + position: absolute; + top: 15px; + right: 15px; +} /* Events & Content */ .events { @@ -422,17 +434,8 @@ details.cw summary { /* Post & Reply */ #newpost { padding: 0px 15px 15px; - display: flex; - flex-direction: row; border-bottom: solid 1px var(--clrBorder); } -#newpost > :first-child { - width: 64px; -} -#newpost > :last-child { - padding-left: 15px; - flex: 1; -} textarea.post-input { display: block; width: 100%; @@ -454,6 +457,7 @@ input[type="text"].cw { font-size: var(--fsReduced); background: transparent; color: var(--clrText); + padding: 5px; } /* Profile */ @@ -528,6 +532,82 @@ code { font-size: var(--fsEnlarged); } +/* Messaging */ + +#dms-not-available { + background: #ffd559; + display: inline-block; + color: #090909; + padding: 15px; +} +.dm-group { + display: flex; + padding: 15px; +} +.dm-group .content { + position: relative; + padding: 0 15px; + flex: 1; +} +.dm-group .message { + word-break: break-word; +} +.dm-group .time { + font-size: var(--fsReduced); + color: var(--clrTextLight); +} +.dm-group .count { + position: absolute; + top: 0; + right: 0; + background: var(--clrBorder); + border-radius: 20px; + padding: 1px 8px; + font-size: var(--fsSmall); +} +.dm-group .count.active { + border: var(--clrNotification) solid 2px; + font-weight: bold; + background: transparent; +} +.event.dm { + padding-bottom: 0; +} +.event.dm:hover { + background: transparent; +} +.event.dm:last-child { + padding-bottom: 15px; +} +.event.dm .wrap{ + border-radius: 20px; + background: var(--clrPanel); + padding: 10px; +} +.event.dm.mine .wrap { + background: #0058ff; + margin-left: auto; +} +.event.dm.mine .timestamp { + color: white; + display: block; + text-align: right; +} +.event.dm .body p { + display: inline-block; + margin: 0; +} +.event.dm .timestamp { + display: block; + margin: 0; +} +.event.dm .reactions { + margin: 0; +} +.event.dm .body { + word-break: break-word; +} + /* Inputs */ .block { diff --git a/css/vars.css b/css/vars.css index 6cf64bd..6d4d156 100644 --- a/css/vars.css +++ b/css/vars.css @@ -2,17 +2,18 @@ :root { /* Colors */ - --clrBg: #fff; - --clrPanel: #f9f9f9; - --clrBorder: #f2f2f2; - --clrBtn: #202020; - --clrText: #202020; - --clrTextLight: #868686; - --clrTextLighter: #abb4ca; - --clrHeart: #ff5050; - --clrWarn: #fbc560; - --clrLink: blue; - --clrLinkVisited: purple; + --clrBg: #fff; + --clrPanel: #f9f9f9; + --clrBorder: #f2f2f2; + --clrBtn: #202020; + --clrText: #202020; + --clrTextLight: #868686; + --clrTextLighter: #abb4ca; + --clrHeart: #ff5050; + --clrWarn: #fbc560; + --clrLink: blue; + --clrLinkVisited: purple; + --clrNotification: #20ff00; /* TODO I don't like these and simply did it for dark mode. To rename. */ --clrEvMsg: #f4f4f4; diff --git a/icon/messages-active.svg b/icon/messages-active.svg new file mode 100644 index 0000000..9900c01 --- /dev/null +++ b/icon/messages-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icon/messages.svg b/icon/messages.svg new file mode 100644 index 0000000..ce1566a --- /dev/null +++ b/icon/messages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html index ad99358..b0f5dae 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,9 @@ + + @@ -69,6 +71,9 @@ + + - - + +
+ + +
-
+
- +
- + - +
@@ -166,6 +173,13 @@
+
+ DMs could not be decrypted due to lack of nip04 + integration. Please use an extension such as nos2x or + Alby. +
+
+
diff --git a/js/core.js b/js/core.js index 87f407b..34b6536 100644 --- a/js/core.js +++ b/js/core.js @@ -15,6 +15,7 @@ const R_HEART = "❤️"; const STANDARD_KINDS = [ KIND_NOTE, + KIND_DM, KIND_DELETE, KIND_REACTION, KIND_SHARE, @@ -126,26 +127,6 @@ async function sign_event(ev) { return ev } -async function send_post() { - const input_el = document.querySelector("#post-input") - const cw_el = document.querySelector("#content-warning-input") - const cw = cw_el.value - const content = input_el.value - const created_at = new_creation_time() - const kind = 1 - const tags = cw ? [["content-warning", cw]] : [] - const pubkey = await get_pubkey() - - let post = { pubkey, tags, content, created_at, kind } - post.id = await nostrjs.calculate_id(post) - post = await sign_event(post) - broadcast_event(post) - - input_el.value = "" - cw_el.value = "" - post_input_changed(input_el) -} - function new_reply_tags(ev) { const tags = [["e", ev.id, "", "reply"]]; if (ev.refs.root) { diff --git a/js/event.js b/js/event.js index 484c963..386edc5 100644 --- a/js/event.js +++ b/js/event.js @@ -8,21 +8,21 @@ function event_refs_pubkey(ev, pubkey) { 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 + return event_tags_pubkey(ev, pubkey) } function event_contains_pubkey(ev, pubkey) { if (ev.pubkey == pubkey) return true; + return event_tags_pubkey(ev, pubkey) +} + +function event_tags_pubkey(ev, pubkey) { for (const tag of ev.tags) { if (tag.length >= 2 && tag[0] == "p" && tag[1] == pubkey) return true; } - return false; + return false } function event_get_pubkeys(ev) { @@ -140,4 +140,3 @@ function event_parse_reaction(ev) { } } - diff --git a/js/main.js b/js/main.js index dcded44..f13aad1 100644 --- a/js/main.js +++ b/js/main.js @@ -8,6 +8,8 @@ const IMG_NO_USER = "icon/no-user.svg"; const SID_META = "meta"; const SID_HISTORY = "history"; const SID_NOTIFICATIONS = "notifications"; +const SID_DMS_OUT = "dms_out"; +const SID_DMS_IN = "dms_in"; const SID_EXPLORE = "explore"; const SID_PROFILES = "profiles"; const SID_THREAD = "thread"; @@ -70,8 +72,10 @@ async function webapp_init() { // WARNING Order Matters! init_message_textareas(); + init_my_pfp(model); + init_postbox(model); + init_profile(); view_show_spinner(true); - redraw_my_pfp(model); // Load data from storage await model_load_settings(model); @@ -80,7 +84,6 @@ async function webapp_init() { if (err) { window.alert("Unable to load contacts."); } - init_settings(model); // Create our pool so that event processing functions can work @@ -135,6 +138,7 @@ function on_timer_save() { } function on_timer_tick() { + //return; setTimeout(() => { DAMUS.relay_que.forEach((que, relay) => { model_fetch_next_profile(DAMUS, relay); @@ -154,13 +158,23 @@ function on_pool_open(relay) { // Get all our info & history, well close this after we get it fetch_profile(pubkey, model.pool, relay); - // Get our notifications. We will never close this. + // Get our notifications relay.subscribe(SID_NOTIFICATIONS, [{ kinds: STANDARD_KINDS, "#p": [pubkey], limit: 5000, }]); + // Get our dms. You have to do 2 separate queries: ours out and others in + relay.subscribe(SID_DMS_IN, [{ + kinds: [KIND_DM], + "#p": [pubkey], + }]); + relay.subscribe(SID_DMS_OUT, [{ + kinds: [KIND_DM], + authors: [pubkey], + }]); + // Subscribe to the world as it will serve our friends, notifications, and // explore views relay.subscribe(SID_EXPLORE, [{ @@ -185,7 +199,6 @@ async function on_pool_eose(relay, sub_id) { log_info(`EOSE(${relay.url}): ${sub_id}`); const model = DAMUS; const { pool } = model; - const index = sub_id.indexOf(":"); const sid = sub_id.slice(0, index >= 0 ? index : sub_id.length); const identifier = sub_id.slice(index+1); @@ -207,6 +220,8 @@ async function on_pool_eose(relay, sub_id) { } case SID_NOTIFICATIONS: case SID_PROFILES: + case SID_DMS_OUT: + case SID_DMS_IN: pool.unsubscribe(sub_id, relay); break; } diff --git a/js/model.js b/js/model.js index 53391fe..297c78f 100644 --- a/js/model.js +++ b/js/model.js @@ -27,6 +27,9 @@ function model_process_event(model, relay, ev) { case KIND_REACTION: fn = model_process_event_reaction; break; + case KIND_DM: + fn = model_process_event_dm; + break; } if (fn) fn(model, ev, !!relay); @@ -45,9 +48,6 @@ function model_process_event(model, relay, ev) { model_que_profile(model, relay, pubkey); } }); - - // TODO fetch unknown referenced events & pubkeys from this event - // TODO notify user of new events aimed at them! } function model_get_relay_que(model, relay) { @@ -123,9 +123,9 @@ function model_process_event_metadata(model, ev, update_view) { // If it's my pubkey let's redraw my pfp that is not located in the view // This has to happen regardless of update_view because of the it's not // related to events - if (profile.pubkey == model.pubkey) { + /*if (profile.pubkey == model.pubkey) { redraw_my_pfp(model); - } + }*/ } function model_has_profile(model, pk) { @@ -153,6 +153,76 @@ function model_process_event_following(model, ev, update_view) { // load_our_relays(model.pubkey, model.pool, ev) } +function event_is_dm(ev, mykey) { + if (ev.kind != KIND_DM) + return false; + if (ev.pubkey != mykey && event_tags_pubkey(ev, mykey)) + return true; + return ev.pubkey == mykey; +} + +/* model_process_event_dm updates the internal dms hash map based on dms + * targeted at the user. + */ +function model_process_event_dm(model, ev, update_view) { + if (!event_is_dm(ev, model.pubkey)) + return; + // We have to identify who the target DM is for since we are also in the + // chat. We simply use the first non-us key we find as the target. I am not + // sure that multi-sig chats are possible at this time in the spec. If no + // target, it's a bad DM. + let target; + const keys = event_get_pubkeys(ev); + for (let key of keys) { + target = key; + if (key == model.pubkey) + continue; + break; + } + if (!target) + return; + let dm = model_get_dm(model, target); + dm.needs_decryption = true; + dm.needs_redraw = true; + // It may be faster to not use binary search due to the newest always being + // at the front - but I could be totally wrong. Potentially it COULD be + // slower during history if history is imported ASCENDINGLY. But anything + // after this will always be faster and is insurance (race conditions). + let i = 0; + for (; i < dm.events.length; i++) { + const b = dm.events[i]; + if (ev.created_at > b.created_at) + break; + } + dm.events.splice(i, 0, ev); + + // Check if DM is new + const b = model.all_events[dm.last_seen]; + if (!b || b.created_at < ev.created_at) { + // TODO update notification UI + dm.new_count++; + } +} + +function model_get_dm(model, target) { + if (!model.dms.has(target)) { + // TODO think about using "pubkey:subject" so we have threads + model.dms.set(target, { + pubkey: target, + // events is an ordered list (new to old) of events referenced from + // all_events. It should not be a copy to reduce memory. + events: [], + // Last read event by the client/user + last_seen: "", + new_count: 0, + // Notifies the renderer that this dm is out of date + needs_redraw: false, + needs_decryption: false, + }); + } + return model.dms.get(target); +} + /* model_process_event_reaction updates the reactions dictionary */ function model_process_event_reaction(model, ev, update_view) { @@ -388,6 +458,7 @@ function new_model() { friends: new Set(), friend_of_friends: new Set(), }, + dms: new Map(), // pubkey => event list invalidated: [], // event ids which should be added/removed elements: {}, // map of evid > rendered element relay_que: new Map(), diff --git a/js/ui/dm.js b/js/ui/dm.js new file mode 100644 index 0000000..ffab531 --- /dev/null +++ b/js/ui/dm.js @@ -0,0 +1,81 @@ +function view_dm_update(model) { + const el = find_node("#dms"); + const order = []; + model.dms.forEach((dm, pubkey, m) => { + if (!dm.events.length) + return; + const i = arr_bsearch_insert(order, dm, dm_cmp); + order.splice(i, 0, dm); + if (!dm.needs_redraw) + return; + let gel = find_node(`[data-pubkey='${pubkey}']`, el); + if (!gel) { + gel = new_el_dmgroup(model, dm); + gel.addEventListener("click", onclick_dm); + el.appendChild(gel); + } + update_el_dmgroup(model, dm, gel); + dm.needs_redraw = false; + }); + + // I'm not sure what is faster, doing a frag update all at once OR just + // updating individual positions. If they all update it's slower, but the + // chances of them all updating is is small and only garuenteed when it + // draws the first time. + //const frag = new DocumentFragment(); + for (let i = 0; i < order.length; i++) { + let dm = order[i]; + let xel = el.children[i]; + if (dm.pubkey == xel.dataset.pubkey) + continue; + let gel = find_node(`[data-pubkey='${order[i].pubkey}']`, el); + el.insertBefore(gel, xel); + //frag.appendChild(gel); + } + //el.appendChild(frag); +} + +function dm_cmp(a, b) { + const x = a.events[0].created_at; + const y = b.events[0].created_at; + if (x > y) + return -1; + if (x < y) + return 1; + return 0; +} + +function update_el_dmgroup(model, dm, el) { + const ev = dm.events[0]; + const profile = model_get_profile(model, dm.pubkey); + const message = ev.decrypted || ev.content || "No Message."; + const time = fmt_datetime(new Date(ev.created_at * 1000)); + const cel = find_node(".count", el) + cel.innerText = dm.new_count; + cel.classList.toggle("active", dm.new_count > 0); + find_node(".time", el).innerText = time; + find_node(".message", el).innerText = message; + find_node(".username", el).innerText = fmt_name(profile); +} + +function new_el_dmgroup(model, dm) { + const profile = model_get_profile(model, dm.pubkey); + return html2el(`
+
${render_profile_img(profile, true)}
+
+
+ +

+ +
+
`); +} + +function onclick_dm(ev) { + const el = find_parent(ev.target, "[data-pubkey]"); + if (!el || !el.dataset.pubkey) { + log_error("did not find dm pubkey"); + return; + } + view_timeline_apply_mode(DAMUS, VM_DM_THREAD, {pubkey: el.dataset.pubkey}); +} diff --git a/js/ui/fmt.js b/js/ui/fmt.js index 73f759e..4928e1e 100644 --- a/js/ui/fmt.js +++ b/js/ui/fmt.js @@ -25,12 +25,13 @@ function linkify(text="", show_media=false) { } function format_content(ev, show_media) { - if (ev.kind === 7) { + if (ev.kind === KIND_REACTION) { if (ev.content === "" || ev.content === "+") return "❤️" return html`${ev.content.trim()}`; } - const content = ev.content.trim(); + const content = (ev.kind == KIND_DM ? ev.decrypted || ev.content : ev.content) + .trim(); const body = fmt_body(content, show_media); let cw = get_content_warning(ev.tags) if (cw !== null) { @@ -72,8 +73,9 @@ function fmt_body(content, show_media) { }, "") } -/* format_profile_name takes in a profile and tries it's best to return a string - * that is best suited for the profile. +/* DEPRECATED: use fmt_name + * format_profile_name takes in a profile and tries it's best to + * return a string that is best suited for the profile. */ function fmt_profile_name(profile={}, fallback="Anonymous") { const name = profile.display_name || profile.user || profile.name || @@ -81,7 +83,23 @@ function fmt_profile_name(profile={}, fallback="Anonymous") { return html`${name}`; } +function fmt_name(profile={data:{}}) { + const { data } = profile; + const name = data.display_name || data.user || data.name || + fmt_pubkey(profile.pubkey); + return html`${name}`; +} + function fmt_pubkey(pk) { + if (!pk) + return "Unknown"; return pk.slice(-8) } +function fmt_datetime(d) { + return d.getFullYear() + + "/" + ("0" + (d.getMonth()+1)).slice(-2) + + "/" + ("0" + d.getDate()).slice(-2) + + " " + ("0" + d.getHours()).slice(-2) + + ":" + ("0" + d.getMinutes()).slice(-2); +} diff --git a/js/ui/profile.js b/js/ui/profile.js new file mode 100644 index 0000000..528b73f --- /dev/null +++ b/js/ui/profile.js @@ -0,0 +1,115 @@ +const PROFILE_FIELDS = [ + 'name', + 'picture', + 'nip05', + 'about', +]; + +function open_profile(pubkey) { + view_timeline_apply_mode(DAMUS, VM_USER, { pubkey }); + view_update_profile(DAMUS, pubkey); +} + +function init_profile() { + const el = find_node("#profile-info"); + const el_pfp = find_node("[role='profile-image']", el); + el_pfp.addEventListener("error", onerror_pfp); + el_pfp.src = IMG_NO_USER; + find_node("[role='message-user']", el) + .addEventListener("click", onclick_message_user); + find_node("[role='edit-profile']", el) + .addEventListener("click", onclick_edit_profile); + find_node("[role='copy-pk']", el) + .addEventListener("click", onclick_copy_pubkey); + find_node("[role='follow-user']", el) + .addEventListener("click", onclick_follow_user); +} + +function onclick_message_user(ev) { + const pubkey = ev.target.dataset.pubkey; + view_timeline_apply_mode(DAMUS, VM_DM_THREAD, { pubkey }); +} + +/* onclick_copy_pubkey writes the element's dataset.pk field to the users OS' + * clipboard. No we don't use fallback APIs, use a recent browser. + */ +function onclick_copy_pubkey(ev) { + const el = ev.target; + navigator.clipboard.writeText(el.dataset.pk); + // TODO show toast +} + +/* onclick_follow_user sends the event to the relay to subscribe the active user + * to the target public key of the element's dataset.pk field. + */ +function onclick_follow_user(ev) { + const el = ev.target; + const { contacts } = DAMUS; + const pubkey = el.dataset.pk; + const is_friend = contacts.friends.has(pubkey); + if (is_friend) { + contacts.friends.delete(pubkey); + } else { + contacts.friends.add(pubkey); + } + el.innerText = is_friend ? "Follow" : "Unfollow"; + contacts_save(contacts); + if (window.confirm("Contacts are saved locally. Do you want to sync you contacts with all relays?")) { + update_contacts(); + } +} + +function view_update_profile(model, pubkey) { + const profile = model_get_profile(model, pubkey); + const el = find_node("#profile-info"); + + const name = fmt_name(profile); + find_node("[role='profile-image']", el).src = get_profile_pic(profile); + find_nodes("[role='profile-name']", el).forEach(el => { + el.innerText = name; + }); + + const el_nip5 = find_node("[role='profile-nip5']", el) + el_nip5.innerText = profile.data.nip05; + el_nip5.classList.toggle("hide", !profile.data.nip05); + + const el_desc = find_node("[role='profile-desc']", el) + el_desc.innerHTML = newlines_to_br(linkify(profile.data.about)); + el_desc.classList.toggle("hide", !profile.data.about); + + find_node("button[role='copy-pk']", el).dataset.pk = pubkey; + find_node("button[role='edit-profile']", el) + .classList.toggle("hide", pubkey != model.pubkey); + + const btn_follow = find_node("button[role='follow-user']", el); + btn_follow.dataset.pk = pubkey; + btn_follow.innerText = contact_is_friend(model.contacts, pubkey) ? "Unfollow" : "Follow"; + btn_follow.classList.toggle("hide", pubkey == model.pubkey); + + const btn_message = find_node("button[role='message-user']", el); + btn_message.dataset.pubkey = pubkey; +} + +function onclick_edit_profile() { + const p = model_get_profile(DAMUS, DAMUS.pubkey); + const el = find_node("#profile-editor"); + el.classList.remove("closed"); + for (const key of PROFILE_FIELDS) { + find_node(`[name='${key}']`, el).value = p.data[key]; + } +} + +function click_update_profile() { + const el = find_node("#profile-editor"); + const btn = find_node("button.action", el); + const p = Object.assign({}, model_get_profile(DAMUS, DAMUS.pubkey).data, { + name: find_node("input[name='name']", el).value, + picture: find_node("input[name='picture']", el).value, + nip05: find_node("input[name='nip05']", el).value, + about: find_node("textarea[name='about']", el).value, + }); + update_profile(p); + close_modal(el); + // TODO show toast that say's "broadcasted!" +} + diff --git a/js/ui/render.js b/js/ui/render.js index 8b27501..3d2f74f 100644 --- a/js/ui/render.js +++ b/js/ui/render.js @@ -54,18 +54,21 @@ function render_shared_by(ev, opts) { } function render_event(model, ev, opts={}) { - if (ev.kind == KIND_SHARE) { - return render_share(model, ev, opts); + switch(ev.kind) { + case KIND_SHARE: + return render_share(model, ev, opts); + case KIND_DM: + return render_dm(model, ev, opts); } const profile = model_get_profile(model, ev.pubkey); const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000) - const border_bottom = opts.is_composing ? "" : "bottom-border"; - let thread_btn = ""; - return html`
+ let classes = "event" + if (!opts.is_composing) + classes += " bottom-border"; + return html`
- $${render_pfp(ev.pubkey, profile.data)} -
+ $${render_profile_img(profile)}
$${render_name(ev.pubkey, profile.data)} @@ -78,12 +81,43 @@ function render_event(model, ev, opts={}) {
` } +function render_dm(model, ev, opts) { + let classes = "event" + if (ev.kind == KIND_DM) { + classes += " dm"; + if (ev.pubkey == model.pubkey) + classes += " mine"; + } + const profile = model_get_profile(model, ev.pubkey); + const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000) + let show_media = event_shows_media(model, ev, model.embeds); + return html`
+
+
+

$${format_content(ev, show_media)}

+
+
${delta}
+
+
` +} + +function event_shows_media(model, ev, mode) { + if (mode == "friends") + return model.contacts.friends.has(ev.pubkey); + return true; +} + +function rerender_dm(model, ev, el) { + let show_media = event_shows_media(model, ev, model.embeds); + find_node(".body > p", el).innerHTML = format_content(ev, show_media); +} + function render_event_nointeract(model, ev, opts={}) { const profile = model_get_profile(model, ev.pubkey); const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000) return html`
- $${render_pfp(ev.pubkey, profile.data)} + $${render_profile_img(profile)}
@@ -114,7 +148,7 @@ function render_event_body(model, ev, opts) { ${format_content(ev, show_media)}

`; str += render_reactions(model, ev); - str += opts.nobar ? "" : + str += opts.nobar || ev.kind == KIND_DM ? "" : render_action_bar(model, ev, {can_delete, shared}); return str; } @@ -237,7 +271,20 @@ function render_name(pk, profile, prefix="") { // Beware of whitespace. return html`${prefix}${fmt_profile_name(profile, fmt_pubkey(pk))}` + > ${fmt_profile_name(profile, fmt_pubkey(pk))}` +} + +function render_profile_img(profile, noclick=false) { + const name = fmt_name(profile); + let str = html`class="pfp clickable" onclick="open_profile('${profile.pubkey}')"`; + if (noclick) + str = "class='pfp'"; + return html`` } function render_pfp(pk, profile, opts={}) { diff --git a/js/ui/state.js b/js/ui/state.js index da07f53..25bf9ca 100644 --- a/js/ui/state.js +++ b/js/ui/state.js @@ -1,6 +1,8 @@ const VM_FRIENDS = "friends"; // mine + only events that are from my contacts const VM_EXPLORE = "explore"; // all events const VM_NOTIFICATIONS = "notifications"; // reactions & replys +const VM_DM = "dm"; // all events of KIND_DM aimmed at user +const VM_DM_THREAD = "dmthread"; // all events from a user of KIND_DM const VM_THREAD = "thread"; // all events in response to target event const VM_USER = "user"; // all events by pubkey const VM_SETTINGS = "settings"; @@ -27,6 +29,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) { // Don't do anything if we are already here if (el.dataset.mode == mode) { switch (mode) { + case VM_DM_THREAD: case VM_USER: if (el.dataset.pubkey == opts.pubkey) return; @@ -39,7 +42,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) { return; } } - + // Push a new state to the browser history stack if (push_state) history.pushState({mode, opts}, ''); @@ -49,7 +52,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) { view_show_spinner(true); fetch_thread_history(thread_id, model.pool); } - if (pubkey && pubkey != model.pubkey) { + if (mode == VM_USER && pubkey && pubkey != model.pubkey) { view_show_spinner(true); fetch_profile(pubkey, model.pool); } @@ -57,11 +60,26 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) { reset_notifications(model); } + const names = {}; + names[VM_FRIENDS] = "Home"; + names[VM_EXPLORE] = "Explore"; + names[VM_NOTIFICATIONS] = "Notifications"; + names[VM_DM] = "Messages (Alpha)"; + names[VM_DM_THREAD] = "Messages"; + names[VM_USER] = "Profile"; + names[VM_THREAD] = "Thread"; + names[VM_SETTINGS] = "Settings"; + let name = names[mode]; + let profile; + el.dataset.mode = mode; switch(mode) { case VM_THREAD: el.dataset.threadId = thread_id; case VM_USER: + case VM_DM_THREAD: + profile = model_get_profile(model, pubkey); + name = fmt_name(profile); el.dataset.pubkey = pubkey; break; default: @@ -70,29 +88,42 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) { break; } - const names = {}; - names[VM_FRIENDS] = "Home"; - names[VM_EXPLORE] = "Explore"; - names[VM_NOTIFICATIONS] = "Notifications"; - names[VM_USER] = "Profile"; - names[VM_THREAD] = "Thread"; - names[VM_SETTINGS] = "Settings"; - // Do some visual updates - find_node("#view header > label").innerText = names[mode]; + find_node("#view header > label").innerText = name; find_node("#nav > div[data-active]").dataset.active = names[mode].toLowerCase(); find_node("#view [role='profile-info']").classList.toggle("hide", mode != VM_USER); - find_node("#newpost").classList.toggle("hide", mode != VM_FRIENDS); + find_node("#newpost").classList.toggle("hide", mode != VM_FRIENDS && mode != VM_DM_THREAD); const timeline_el = find_node("#timeline"); timeline_el.classList.toggle("reverse", mode == VM_THREAD); - timeline_el.classList.toggle("hide", mode == VM_SETTINGS); + timeline_el.classList.toggle("hide", mode == VM_SETTINGS || mode == VM_DM); find_node("#settings").classList.toggle("hide", mode != VM_SETTINGS); + find_node("#dms").classList.toggle("hide", mode != VM_DM); + find_node("#dms-not-available") + .classList.toggle("hide", mode == VM_DM_THREAD || mode == VM_DM ? + dms_available() : true); + + // Show/hide different profile image in header + el_their_pfp = find_node("#view header > img.pfp[role='their-pfp']"); + el_their_pfp.classList.toggle("hide", mode != VM_DM_THREAD); + find_node("#view header > img.pfp[role='my-pfp']") + .classList.toggle("hide", mode == VM_DM_THREAD); view_timeline_refresh(model, mode, opts); - if (mode == VM_SETTINGS) { - view_show_spinner(false); - view_set_show_count(0, true, true); + switch (mode) { + case VM_DM_THREAD: + decrypt_dms(model); + el_their_pfp.src = get_profile_pic(profile); + el_their_pfp.dataset.pubkey = pubkey; + break; + case VM_DM: + decrypt_dms(model); + view_dm_update(model); + break; + case VM_SETTINGS: + view_show_spinner(false); + view_set_show_count(0, true, true); + break; } return mode; @@ -112,7 +143,9 @@ function view_timeline_refresh(model, mode, opts={}) { el.innerHTML = ""; // Build DOM fragment and render it let count = 0; - const evs = model_events_arr(model) + const evs = mode == VM_DM_THREAD ? + model_get_dm(model, opts.pubkey).events.concat().reverse() : + model_events_arr(model); const fragment = new DocumentFragment(); for (let i = evs.length - 1; i >= 0 && count < 1000; i--) { const ev = evs[i]; @@ -149,6 +182,7 @@ function view_timeline_update(model) { let count = 0; let ncount = 0; + let decrypted = false; const latest_ev = el.firstChild ? model.all_events[el.firstChild.id.slice(2)] : undefined; const left_overs = []; @@ -165,9 +199,16 @@ function view_timeline_update(model) { continue; } - // Skip non-renderables and already created + // Skip non-renderables var ev = model.all_events[evid]; - if (!event_is_renderable(ev) || model.elements[evid]) { + if (!event_is_renderable(ev)) { + continue; + } + + // Re-render content of a decrypted dm + if (ev.kind == KIND_DM && model.elements[evid]) { + rerender_dm(model, ev, model.elements[evid]); + decrypted = true; continue; } @@ -205,6 +246,10 @@ function view_timeline_update(model) { model.notifications.count += ncount; update_notifications(model); } + // Update the dms list view + if (decrypted) { + view_dm_update(model); + } } function view_set_show_count(count, add=false, hide=false) { @@ -251,6 +296,7 @@ function view_timeline_show_new(model) { } view_set_show_count(-count, true); view_timeline_update_timestamps(); + if (mode == VM_DM_THREAD) decrypt_dms(model); } function view_render_event(model, ev, force=false) { @@ -269,29 +315,42 @@ function view_render_event(model, ev, force=false) { } function view_timeline_update_profiles(model, pubkey) { - let xs, html; const el = view_get_timeline_el(); const p = model_get_profile(model, pubkey); - const name = fmt_profile_name(p.data, fmt_pubkey(pubkey)); - const pic = get_picture(pubkey, p.data) + const name = fmt_name(p); + const pic = get_profile_pic(p); for (const evid in model.elements) { if (!event_contains_pubkey(model.all_events[evid], pubkey)) continue; - const el = model.elements[evid]; - let xs; - xs = find_nodes(`.username[data-pubkey='${pubkey}']`, el) - xs.forEach((el)=> { - el.innerText = name; - }); - xs = find_nodes(`img[data-pubkey='${pubkey}']`, el) - xs.forEach((el)=> { - el.src = pic; - }); + update_el_profile(model.elements[evid], pubkey, name, pic); } // Update the profile view if it's active - if (el.dataset.mode == VM_USER && el.dataset.pubkey == pubkey) { - view_update_profile(model, pubkey); + if (el.dataset.pubkey == pubkey) { + const mode = el.dataset.mode; + switch (mode) { + case VM_USER: + view_update_profile(model, pubkey); + case VM_DM_THREAD: + find_node("#view header > label").innerText = name; + } } + // Update dm's section since they are not in our view, dm's themselves will + // be caught by the process above. + update_el_profile(find_node("#dms"), pubkey, name, pic); + update_el_profile(find_node("#view header"), pubkey, name, pic); + update_el_profile(find_node("#newpost"), pubkey, name, pic); +} + +function update_el_profile(el, pubkey, name, pic) { + if (!el) + return; + find_nodes(`.username[data-pubkey='${pubkey}']`, el).forEach((el)=> { + el.innerText = name; + }); + find_nodes(`img[data-pubkey='${pubkey}']`, el).forEach((el)=> { + el.src = pic; + el.title = name; + }); } function view_timeline_update_timestamps() { @@ -336,6 +395,9 @@ function view_timeline_update_reaction(model, ev) { } function view_mode_contains_event(model, ev, mode, opts={}) { + if (mode != VM_DM_THREAD && ev.kind == KIND_DM) { + return false; + } switch(mode) { case VM_EXPLORE: return ev.kind != KIND_REACTION; @@ -349,13 +411,19 @@ function view_mode_contains_event(model, ev, mode, opts={}) { ev.refs.root == opts.thread_id || ev.refs.reply == opts.thread_id)); case VM_NOTIFICATIONS: - return event_refs_pubkey(ev, model.pubkey); + return event_tags_pubkey(ev, model.pubkey); + case VM_DM_THREAD: + if (ev.kind != KIND_DM) return false; + return (ev.pubkey == opts.pubkey && + event_tags_pubkey(ev, model.pubkey)) || + (ev.pubkey == model.pubkey && + event_tags_pubkey(ev, opts.pubkey)); } return false; } function event_is_renderable(ev={}) { - return ev.kind == KIND_NOTE || ev.kind == KIND_SHARE; + return ev.kind == KIND_NOTE || ev.kind == KIND_SHARE || ev.kind == KIND_DM; } function get_default_max_depth(damus, view) { @@ -364,18 +432,17 @@ function get_default_max_depth(damus, view) { function get_thread_max_depth(damus, view, root_id) { if (!view.depths[root_id]) - return get_default_max_depth(damus, view) - - return view.depths[root_id] + return get_default_max_depth(damus, view); + return view.depths[root_id]; } function get_thread_root_id(damus, id) { - const ev = damus.all_events[id] + const ev = damus.all_events[id]; if (!ev) { log_debug("expand_thread: no event found?", id) - return null + return null; } - return ev.refs && ev.refs.root + return ev.refs && ev.refs.root; } function switch_view(mode, opts) { @@ -389,3 +456,89 @@ function reset_notifications(model) { update_notifications(model); } +function html2el(html) { + const div = document.createElement("div"); + div.innerHTML = html; + return div.firstChild; +} + +function init_my_pfp(model) { + find_nodes(`img[role='my-pfp']`).forEach((el)=> { + el.dataset.pubkey = model.pubkey; + el.addEventListener("error", onerror_pfp); + el.addEventListener("click", onclick_pfp); + el.classList.add("clickable"); + }); + find_nodes(`img[role='their-pfp']`).forEach((el)=> { + el.addEventListener("error", onerror_pfp); + el.addEventListener("click", onclick_pfp); + el.classList.add("clickable"); + }); +} + +function init_postbox(model) { + const el = find_node("#newpost"); + find_node("textarea", el).addEventListener("input", oninput_post); + find_node("button[role='send']").addEventListener("click", onclick_send); + find_node("button[role='toggle-cw']") + .addEventListener("click", onclick_toggle_cw); +} +async function onclick_send(ev) { + const el = view_get_timeline_el(); + const mode = el.dataset.mode; + const pubkey = await get_pubkey(); + const el_input = document.querySelector("#post-input"); + const el_cw = document.querySelector("#content-warning-input"); + let post = { + pubkey, + kind: KIND_NOTE, + created_at: new_creation_time(), + content: el_input.value, + tags: el_cw.value ? [["content-warning", el_cw.value]] : [], + } + + // Handle DM type post + if (mode == VM_DM_THREAD) { + if (!dms_available()) { + window.alert("DMing not available."); + return; + } + post.kind = KIND_DM; + const target = el.dataset.pubkey; + post.tags.splice(0, 0, ["p", target]); + post.content = await window.nostr.nip04.encrypt(target, post.content); + } + + // Send it + post.id = await nostrjs.calculate_id(post) + post = await sign_event(post) + broadcast_event(post); + + // Reset UI + el_input.value = ""; + el_cw.value = ""; + trigger_postbox_assess(el_input); +} +/* oninput_post checks the content of the textarea and updates the size + * of it's element. Additionally I will toggle the enabled state of the sending + * button. + */ +function oninput_post(ev) { + trigger_postbox_assess(ev.target); +} +function trigger_postbox_assess(el) { + el.style.height = `0px`; + el.style.height = `${el.scrollHeight}px`; + let btn = el.parentElement.querySelector("button[role=send]"); + if (btn) btn.disabled = el.value === ""; +} +/* toggle_cw changes the active stage of the Content Warning for a post. It is + * relative to the element that is pressed. + */ +function onclick_toggle_cw(ev) { + const el = ev.target; + el.classList.toggle("active"); + const isOn = el.classList.contains("active"); + const input = el.parentElement.querySelector("input.cw"); + input.classList.toggle("hide", !isOn); +} diff --git a/js/ui/util.js b/js/ui/util.js index da08754..27667c7 100644 --- a/js/ui/util.js +++ b/js/ui/util.js @@ -3,16 +3,6 @@ * this file grows specific UI area code should be migrated to its own file. */ -/* toggle_cw changes the active stage of the Content Warning for a post. It is - * relative to the element that is pressed. - */ -function toggle_cw(el) { - el.classList.toggle("active"); - const isOn = el.classList.contains("active"); - const input = el.parentElement.querySelector("input.cw"); - input.classList.toggle("hide", !isOn); -} - /* toggle_gnav hides or shows the global navigation's additional buttons based * on its opened state. */ @@ -24,17 +14,6 @@ function close_gnav() { find_node("#gnav").classList.remove("open"); } -/* post_input_changed checks the content of the textarea and updates the size - * of it's element. Additionally I will toggle the enabled state of the sending - * button. - */ -function post_input_changed(el) { - el.style.height = `0px`; - el.style.height = `${el.scrollHeight}px`; - let btn = el.parentElement.querySelector("button[role=send]"); - if (btn) btn.disabled = el.value === ""; -} - /* init_message_textareas finds all message textareas and updates their initial * height based on their content (0). This is so there is no jaring affect when * the page loads. @@ -42,7 +21,7 @@ function post_input_changed(el) { function init_message_textareas() { const els = document.querySelectorAll(".post-input"); for (const el of els) { - post_input_changed(el); + trigger_postbox_assess(el); } } @@ -56,39 +35,6 @@ function update_notification_markers(active) { } } -/* show_profile updates the current view to the profile display and updates the - * information to the relevant profile based on the public key passed. - * TODO handle async waiting for relay not yet connected - */ -function show_profile(pk) { - switch_view("profile"); - const model = DAMUS; - const profile = model_get_profile(model, pk).data; - const el = find_node("#profile-view"); - // TODO show loading indicator then render - - find_node("[role='profile-image']", el).src = get_picture(pk, profile); - find_nodes("[role='profile-name']", el).forEach(el => { - el.innerText = fmt_profile_name(profile, fmt_pubkey(pk)); - }); - - const el_nip5 = find_node("[role='profile-nip5']", el) - el_nip5.innerText = profile.nip05; - el_nip5.classList.toggle("hide", !profile.nip05); - - const el_desc = find_node("[role='profile-desc']", el) - el_desc.innerHTML = newlines_to_br(profile.about); - el_desc.classList.toggle("hide", !profile.about); - - find_node("button[role='copy-pk']", el).dataset.pk = pk; - - const btn_follow = find_node("button[role='follow-user']", el) - btn_follow.dataset.pk = pk; - // TODO check follow status - btn_follow.innerText = 1 == 1 ? "Follow" : "Unfollow"; - btn_follow.classList.toggle("hide", pk == model.pubkey); -} - /* newlines_to_br takes a string and converts all newlines to HTML 'br' tags. */ function newlines_to_br(str="") { @@ -97,33 +43,6 @@ function newlines_to_br(str="") { }, ""); } -/* click_copy_pk writes the element's dataset.pk field to the users OS' - * clipboard. No we don't use fallback APIs, use a recent browser. - */ -function click_copy_pk(el) { - // TODO show toast - navigator.clipboard.writeText(el.dataset.pk); -} - -/* click_follow_user sends the event to the relay to subscribe the active user - * to the target public key of the element's dataset.pk field. - */ -function click_toggle_follow_user(el) { - const { contacts } = DAMUS; - const pubkey = el.dataset.pk; - const is_friend = contacts.friends.has(pubkey); - if (is_friend) { - contacts.friends.delete(pubkey); - } else { - contacts.friends.add(pubkey); - } - el.innerText = is_friend ? "Follow" : "Unfollow"; - contacts_save(contacts); - if (window.confirm("Contacts are saved locally. Do you want to sync you contacts with all relays?")) { - update_contacts(); - } -} - function show_new() { view_timeline_show_new(DAMUS); } @@ -207,12 +126,12 @@ function reply_all(evid) { reply(evid, true); } -function redraw_my_pfp(model) { +/*function redraw_my_pfp(model) { const p = model_get_profile(model, model.pubkey).data; const html = render_pfp(model.pubkey, p || {}); const el = document.querySelector(".my-userpic"); el.innerHTML = html; -} +}*/ function update_favicon(path) { let link = document.querySelector("link[rel~='icon']"); @@ -279,11 +198,6 @@ function open_thread(thread_id) { view_timeline_apply_mode(DAMUS, VM_THREAD, { thread_id }); } -function open_profile(pubkey) { - view_timeline_apply_mode(DAMUS, VM_USER, { pubkey }); - view_update_profile(DAMUS, pubkey); -} - function open_faqs() { find_node("#faqs").classList.remove("closed"); } @@ -298,61 +212,6 @@ function close_modal(el) { } } -function view_update_profile(model, pubkey) { - const profile = model_get_profile(model, pubkey); - const el = find_node("[role='profile-info']"); - - const name = fmt_profile_name(profile.data, fmt_pubkey(pubkey)); - find_node("#view header > label").innerText = name; - find_node("[role='profile-image']", el).src = get_picture(pubkey, profile.data); - find_nodes("[role='profile-name']", el).forEach(el => { - el.innerText = name; - }); - - const el_nip5 = find_node("[role='profile-nip5']", el) - el_nip5.innerText = profile.data.nip05; - el_nip5.classList.toggle("hide", !profile.data.nip05); - - const el_desc = find_node("[role='profile-desc']", el) - el_desc.innerHTML = newlines_to_br(linkify(profile.data.about)); - el_desc.classList.toggle("hide", !profile.data.about); - - find_node("button[role='copy-pk']", el).dataset.pk = pubkey; - find_node("button[role='edit-profile']", el) - .classList.toggle("hide", pubkey != model.pubkey); - - const btn_follow = find_node("button[role='follow-user']", el) - btn_follow.dataset.pk = pubkey; - // TODO check follow status - btn_follow.innerText = contact_is_friend(model.contacts, pubkey) ? "Unfollow" : "Follow"; - btn_follow.classList.toggle("hide", pubkey == model.pubkey); -} - -const PROFILE_FIELDS = ['name', 'picture', 'nip05', 'about']; - -function show_profile_editor() { - const p = model_get_profile(DAMUS, DAMUS.pubkey); - const el = find_node("#profile-editor"); - el.classList.remove("closed"); - for (const key of PROFILE_FIELDS) { - find_node(`[name='${key}']`, el).value = p.data[key]; - } -} - -function click_update_profile() { - const el = find_node("#profile-editor"); - const btn = find_node("button.action", el); - const p = Object.assign({}, model_get_profile(DAMUS, DAMUS.pubkey).data, { - name: find_node("input[name='name']", el).value, - picture: find_node("input[name='picture']", el).value, - nip05: find_node("input[name='nip05']", el).value, - about: find_node("textarea[name='about']", el).value, - }); - update_profile(p); - close_modal(el); - // TODO show toast that say's "broadcasted!" -} - function on_click_show_event_details(evid) { const model = DAMUS; const ev = model.all_events[evid]; @@ -362,3 +221,11 @@ function on_click_show_event_details(evid) { el.classList.remove("closed"); find_node("code", el).innerText = JSON.stringify(ev, null, "\t"); } + +function onclick_pfp(ev) { + open_profile(ev.target.dataset.pubkey); +} + +function onerror_pfp(ev) { + ev.target.src = IMG_NO_USER; +} diff --git a/js/util.js b/js/util.js index f3033af..0752eac 100644 --- a/js/util.js +++ b/js/util.js @@ -56,7 +56,8 @@ function shuffle(arr) { } /* arr_bsearch_insert finds the point in the array that an item should be - * inserted at based on the 'cmp' function used. + * inserted at based on the 'cmp' function used. cmp function is same as sort + * cmp function. */ function arr_bsearch_insert(arr, item, cmp) { let start = 0; @@ -113,6 +114,13 @@ function find_nodes(selector, parentEl) { return (parentEl || document).querySelectorAll(selector); } +function find_parent(el, selector) { + while (el && !el.matches(selector)) { + el = el.parentNode; + } + return el; +} + /* uuidv4 returns a new uuid v4 */ function uuidv4() { @@ -225,7 +233,15 @@ function get_qs(loc=location.href) { return new URL(loc).searchParams } -function get_picture(pk, profile) { +function get_profile_pic(profile) { + if (profile && profile.data && profile.data.picture) + return html`${profile.data.picture}`; + return IMG_NO_USER; +} + +/* DEPRECATED use get_profile_picture + */ +function get_picture(profile) { if (!profile || !profile.picture) return IMG_NO_USER; return html`${profile.picture}`; @@ -247,3 +263,36 @@ function process_json_content(ev) { ev.json_content = safe_parse_json(ev.content, "event json_content"); } +function dms_available() { + return window.nostr && window.nostr.nip04; +} + +async function decrypt_dms(model) { + if (!dms_available()) { + log_warn("could not decrypt messages because nostr.nip04 is not available"); + return false; + } + for (const item of model.dms) { + let dm = item[1]; + if (!dm.needs_decryption) + continue; + for (const ev of dm.events) { + if (ev.decrypted != undefined) + continue; + let str; + try { + str = await window.nostr.nip04.decrypt(dm.pubkey, ev.content); + } catch (err) { + log_error("unable to decrypt dm", ev.id, err); + } + if (!str) + continue; + ev.decrypted = str; + model.invalidated.push(ev.id); + } + dm.needs_decryption = false; + dm.needs_redraw = true; + } + return true; +} +