
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.
544 lines
15 KiB
JavaScript
544 lines
15 KiB
JavaScript
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";
|
|
|
|
function view_get_timeline_el() {
|
|
return find_node("#timeline");
|
|
}
|
|
|
|
// TODO clean up popstate listener (move to init method or such)
|
|
window.addEventListener("popstate", function(event) {
|
|
if (event.state && event.state.mode) {
|
|
// Update the timeline mode.
|
|
// Pass pushState=false to avoid adding another state to the history
|
|
view_timeline_apply_mode(DAMUS, event.state.mode, event.state.opts, false);
|
|
}
|
|
})
|
|
|
|
function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
|
|
let xs;
|
|
const { pubkey, thread_id } = opts;
|
|
const el = view_get_timeline_el();
|
|
const now = new Date().getTime();
|
|
|
|
// 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;
|
|
break;
|
|
case VM_THREAD:
|
|
if (el.dataset.threadId == thread_id)
|
|
return;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Push a new state to the browser history stack
|
|
if (push_state)
|
|
history.pushState({mode, opts}, '');
|
|
|
|
// Fetch history for certain views
|
|
if (mode == VM_THREAD) {
|
|
view_show_spinner(true);
|
|
fetch_thread_history(thread_id, model.pool);
|
|
}
|
|
if (mode == VM_USER && pubkey && pubkey != model.pubkey) {
|
|
view_show_spinner(true);
|
|
fetch_profile(pubkey, model.pool);
|
|
}
|
|
if (mode == VM_NOTIFICATIONS) {
|
|
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:
|
|
delete el.dataset.threadId;
|
|
delete el.dataset.pubkey;
|
|
break;
|
|
}
|
|
|
|
// Do some visual updates
|
|
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 && 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 || 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);
|
|
|
|
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;
|
|
}
|
|
|
|
/* view_timeline_refresh is a hack for redrawing the events in order
|
|
*/
|
|
function view_timeline_refresh(model, mode, opts={}) {
|
|
const el = view_get_timeline_el();
|
|
if (!mode) {
|
|
mode = el.dataset.mode;
|
|
opts.thread_id = el.dataset.threadId;
|
|
opts.pubkey = el.dataset.pubkey;
|
|
}
|
|
// Remove all
|
|
// This is faster than removing one by one
|
|
el.innerHTML = "";
|
|
// Build DOM fragment and render it
|
|
let count = 0;
|
|
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];
|
|
if (!view_mode_contains_event(model, ev, mode, opts))
|
|
continue;
|
|
let ev_el = model.elements[ev.id];
|
|
if (!ev_el)
|
|
continue;
|
|
fragment.appendChild(ev_el);
|
|
count++;
|
|
}
|
|
if (count > 0) {
|
|
el.append(fragment);
|
|
view_set_show_count(0);
|
|
view_timeline_update_timestamps();
|
|
view_show_spinner(false);
|
|
}
|
|
}
|
|
|
|
function view_show_spinner(show=true) {
|
|
find_node("#view .loading-events").classList.toggle("hide", !show);
|
|
}
|
|
|
|
/* view_timeline_update iterates through invalidated event ids and updates the
|
|
* state of the timeline and other factors such as notifications, etc.
|
|
*/
|
|
function view_timeline_update(model) {
|
|
const el = view_get_timeline_el();
|
|
const mode = el.dataset.mode;
|
|
const opts = {
|
|
thread_id: el.dataset.threadId,
|
|
pubkey: el.dataset.pubkey,
|
|
};
|
|
|
|
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 = [];
|
|
while (model.invalidated.length > 0 && count < 500) {
|
|
var evid = model.invalidated.pop();
|
|
|
|
// Remove deleted events first
|
|
if (model_is_event_deleted(model, evid)) {
|
|
let x = model.elements[evid];
|
|
if (x && x.parentElement) {
|
|
x.parentElement.removeChild(x);
|
|
delete model.elements[evid];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip non-renderables
|
|
var ev = model.all_events[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;
|
|
}
|
|
|
|
// Put it back on the stack to re-render if it's not ready.
|
|
if (!view_render_event(model, ev)) {
|
|
left_overs.push(evid);
|
|
continue;
|
|
}
|
|
|
|
// Increase notification count if needed
|
|
if (event_refs_pubkey(ev, model.pubkey) &&
|
|
ev.created_at > model.notifications.last_viewed) {
|
|
ncount++;
|
|
}
|
|
|
|
// If the new element is newer than the latest & is viewable then
|
|
// we want to increase the count of how many to add to view
|
|
if (event_cmp_created(ev, latest_ev) >= 0 &&
|
|
view_mode_contains_event(model, ev, mode, opts)) {
|
|
count++;
|
|
}
|
|
}
|
|
model.invalidated = model.invalidated.concat(left_overs);
|
|
|
|
// If there are new things to show on our current view lets do it
|
|
if (count > 0) {
|
|
if (!latest_ev) {
|
|
view_timeline_show_new(model);
|
|
}
|
|
view_set_show_count(count, true, false);
|
|
}
|
|
// Update notification markers and count
|
|
if (ncount > 0) {
|
|
log_debug(`new notis ${ncount}`);
|
|
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) {
|
|
const show_el = find_node("#show-new")
|
|
const num_el = find_node("#show-new span", show_el);
|
|
if (add) {
|
|
count += parseInt(num_el.innerText || 0)
|
|
}
|
|
num_el.innerText = count;
|
|
show_el.classList.toggle("hide", hide || count <= 0);
|
|
}
|
|
|
|
function view_timeline_show_new(model) {
|
|
const el = view_get_timeline_el();
|
|
const mode = el.dataset.mode;
|
|
const opts = {
|
|
thread_id: el.dataset.threadId,
|
|
pubkey: el.dataset.pubkey,
|
|
};
|
|
const latest_evid = el.firstChild ? el.firstChild.id.slice(2) : undefined;
|
|
|
|
let count = 0;
|
|
const evs = model_events_arr(model)
|
|
const fragment = new DocumentFragment();
|
|
for (let i = evs.length - 1; i >= 0 && count < 500; i--) {
|
|
const ev = evs[i];
|
|
if (latest_evid && ev.id == latest_evid) {
|
|
break;
|
|
}
|
|
if (!view_mode_contains_event(model, ev, mode, opts))
|
|
continue;
|
|
let ev_el = model.elements[ev.id];
|
|
if (!ev_el)
|
|
continue;
|
|
fragment.appendChild(ev_el);
|
|
count++;
|
|
}
|
|
if (count > 0) {
|
|
el.prepend(fragment);
|
|
view_show_spinner(false);
|
|
if (mode == VM_NOTIFICATIONS) {
|
|
reset_notifications(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) {
|
|
if (model.elements[ev.id] && !force)
|
|
return model.elements[ev.id];
|
|
const html = render_event(model, ev, {});
|
|
if (html == "") {
|
|
//log_debug(`failed to render ${ev.id}`);
|
|
return;
|
|
}
|
|
const div = document.createElement("div");
|
|
div.innerHTML = html;
|
|
const el = div.firstChild;
|
|
model.elements[ev.id] = el;
|
|
return el;
|
|
}
|
|
|
|
function view_timeline_update_profiles(model, pubkey) {
|
|
const el = view_get_timeline_el();
|
|
const p = model_get_profile(model, pubkey);
|
|
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;
|
|
update_el_profile(model.elements[evid], pubkey, name, pic);
|
|
}
|
|
// Update the profile view if it's active
|
|
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() {
|
|
// TODO only update elements that are fresh and are in DOM
|
|
const el = view_get_timeline_el();
|
|
let xs = el.querySelectorAll(".timestamp");
|
|
let now = new Date().getTime();
|
|
for (const x of xs) {
|
|
let t = parseInt(x.dataset.timestamp)
|
|
x.innerText = fmt_since_str(now, t*1000);
|
|
}
|
|
}
|
|
|
|
function view_timeline_update_reaction(model, ev) {
|
|
let el;
|
|
const o = event_parse_reaction(ev);
|
|
if (!o)
|
|
return;
|
|
const ev_id = o.e;
|
|
const root = model.elements[ev_id];
|
|
if (!root)
|
|
return;
|
|
|
|
// Update reaction groups
|
|
el = find_node(`.reactions`, root);
|
|
el.innerHTML = render_reactions_inner(model, model.all_events[ev_id]);
|
|
|
|
// Update like button
|
|
if (ev.pubkey == model.pubkey) {
|
|
const reaction = model_get_reacts_to(model, model.pubkey, ev_id, R_HEART);
|
|
const liked = !!reaction;
|
|
const img = find_node("button.icon.heart > img", root);
|
|
const btn = find_node("button.icon.heart", root)
|
|
btn.classList.toggle("liked", liked);
|
|
btn.title = liked ? "Unlike" : "Like";
|
|
btn.disabled = false;
|
|
btn.dataset.liked = liked ? "yes" : "no";
|
|
btn.dataset.reactionId = liked ? reaction.id : "";
|
|
img.classList.toggle("dark-noinvert", liked);
|
|
img.src = liked ? IMG_EVENT_LIKED : IMG_EVENT_LIKE;
|
|
}
|
|
}
|
|
|
|
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;
|
|
case VM_USER:
|
|
return opts.pubkey && ev.pubkey == opts.pubkey;
|
|
case VM_FRIENDS:
|
|
return ev.pubkey == model.pubkey || contact_is_friend(model.contacts, ev.pubkey);
|
|
case VM_THREAD:
|
|
if (ev.kind == KIND_SHARE) return false;
|
|
return ev.id == opts.thread_id || (ev.refs && (
|
|
ev.refs.root == opts.thread_id ||
|
|
ev.refs.reply == opts.thread_id));
|
|
case VM_NOTIFICATIONS:
|
|
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 || ev.kind == KIND_DM;
|
|
}
|
|
|
|
function get_default_max_depth(damus, view) {
|
|
return view.max_depth || damus.max_depth
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
function get_thread_root_id(damus, id) {
|
|
const ev = damus.all_events[id];
|
|
if (!ev) {
|
|
log_debug("expand_thread: no event found?", id)
|
|
return null;
|
|
}
|
|
return ev.refs && ev.refs.root;
|
|
}
|
|
|
|
function switch_view(mode, opts) {
|
|
view_timeline_apply_mode(DAMUS, mode, opts);
|
|
close_gnav();
|
|
}
|
|
|
|
function reset_notifications(model) {
|
|
model.notifications.count = 0;
|
|
model.notifications.last_viewed = new_creation_time();
|
|
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);
|
|
}
|