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.
This commit is contained in:
parent
9badc35bf3
commit
077bf49fdb
17 changed files with 798 additions and 292 deletions
237
js/ui/state.js
237
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);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue