yosup/js/ui/state.js
2023-03-23 19:19:15 -07:00

756 lines
21 KiB
JavaScript

const VM_FRIENDS = "friends"; // mine + only events that are from my contacts
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";
const VIEW_NAMES= {};
VIEW_NAMES[VM_FRIENDS] = "Home";
VIEW_NAMES[VM_NOTIFICATIONS] = "Notifications";
VIEW_NAMES[VM_DM] = "Messages";
VIEW_NAMES[VM_DM_THREAD] = "DM";
VIEW_NAMES[VM_USER] = "Profile";
VIEW_NAMES[VM_THREAD] = "Thread";
VIEW_NAMES[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();
if (opts.hide_replys == undefined) {
opts.hide_replys = el.dataset.hideReplys == "true";
}
// Don't do anything if we are already here
if (el.dataset.mode == mode) {
switch (mode) {
case VM_FRIENDS:
if ((el.dataset.hideReplys == "true") == opts.hide_replys)
return;
push_state = false;
break;
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;
}
}
// 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 = VIEW_NAMES;
let name = names[mode];
let profile;
// Push a new state to the browser history stack
if (push_state) {
let pieces = [name.toLowerCase()];
switch (mode) {
case VM_FRIENDS:
pieces = [];
break;
case VM_THREAD:
pieces.push(thread_id);
break;
case VM_USER:
case VM_DM_THREAD:
pieces.push(pubkey);
break;
}
history.pushState({mode, opts}, "", "/"+pieces.join("/"));
}
el.dataset.mode = mode;
delete el.dataset.threadId;
delete el.dataset.pubkey;
switch(mode) {
case VM_FRIENDS:
el.dataset.hideReplys = opts.hide_replys;
break;
case VM_THREAD:
el.dataset.threadId = thread_id;
break;
case VM_USER:
case VM_DM_THREAD:
profile = model_get_profile(model, pubkey);
name = fmt_name(profile);
el.dataset.pubkey = pubkey;
break;
}
// Do some visual updates
find_node("#show-more").classList.add("hide");
find_node("#view header > label").innerText = name;
view_update_navs(mode);
find_node("#view [role='profile-info']").classList.toggle("hide", mode != VM_USER);
const timeline_el = find_node("#timeline");
timeline_el.classList.toggle("reverse", mode == VM_THREAD || mode == VM_DM_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("#dm-post").classList.toggle("hide", mode != VM_DM_THREAD);
find_node("#new-note-mobile").classList.toggle("hide", mode == VM_DM_THREAD);
find_node("#header-tools button[action='mark-all-read']")
.classList.toggle("hide", mode != VM_DM);
// Show/hide different profile image in header
const show_mypfp = mode != VM_DM_THREAD && mode != VM_USER;
const el_their_pfp = find_node("#view header img.pfp[role='their-pfp']");
el_their_pfp.classList.toggle("hide", show_mypfp);
find_node("#view header img.pfp[role='my-pfp']")
.classList.toggle("hide", !show_mypfp);
view_timeline_refresh(model, mode, opts);
switch (mode) {
case VM_DM_THREAD:
decrypt_dms(model);
model_dm_seen(model, pubkey);
el_their_pfp.src = get_profile_pic(profile);
el_their_pfp.dataset.pubkey = pubkey;
break;
case VM_DM:
model.dms_need_redraw = true;
view_show_spinner(true);
view_set_show_count(0, true, true);
//decrypt_dms(model);
//view_dm_update(model);
break;
case VM_SETTINGS:
view_show_spinner(false);
view_set_show_count(0, true, true);
break;
case VM_USER:
el_their_pfp.src = get_profile_pic(profile);
el_their_pfp.dataset.pubkey = pubkey;
view_update_profile(model, pubkey);
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;
opts.hide_replys = el.dataset.hideReplys == "true";
}
// Remove all
// This is faster than removing one by one
el.innerHTML = "";
// Build DOM fragment and render it
let count = 0;
const limit = 200;
const evs = mode == VM_DM_THREAD ?
model_get_dm(model, opts.pubkey).events.concat().reverse() :
model_events_arr(model);
const fragment = new DocumentFragment();
let show_more = true;
let i = evs.length - 1;
for (; i >= 0 && count < limit; 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);
}
// Reduce i to 0 if there are no more events to show, otherwise exit at
// first instance. Determine show_more base on these facts
for (; i > 0; i--) {
if (view_mode_contains_event(model, evs[i], mode, opts))
break;
}
if (i < 0 || count < limit)
show_more = false;
// If we reached the limit there is "probably" more to show so show
// the more button
const is_more_mode = mode == VM_FRIENDS || mode == VM_NOTIFICATIONS;
if (is_more_mode && show_more) {
find_node("#show-more").classList.remove("hide");
}
}
function view_update_navs(mode) {
find_nodes("nav.nav button[data-view]").forEach((el)=> {
el.classList.toggle("active", el.dataset.view == mode)
});
}
function view_show_spinner(show=true) {
find_node("#view .loading-events").classList.toggle("hide", !show);
}
function view_get_el_opts(el) {
const mode = el.dataset.mode;
return {
thread_id: el.dataset.threadId,
pubkey: el.dataset.pubkey,
hide_replys: mode == VM_FRIENDS && el.dataset.hideReplys == "true",
};
}
/* 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 = view_get_el_opts(el);
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 || mode == VM_DM_THREAD) {
view_timeline_show_new(model);
}
if (mode == VM_DM_THREAD) {
model_mark_dms_seen(model, opts.pubkey);
view_dm_update(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 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 = view_get_el_opts(el);
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_timeline_show_more(model) {
const el = view_get_timeline_el();
const mode = el.dataset.mode;
const opts = view_get_el_opts(el);
const oldest_evid = el.lastElementChild ? el.lastElementChild.id.slice(2) : undefined;
const oldest = model.all_events[oldest_evid];
const evs = model_events_arr(model);
const fragment = new DocumentFragment();
let i = arr_bsearch(evs, oldest, (a, b) => {
if (a.id == b.id || a.created_at == b.created_at)
return 0;
if (a.created_at > b.created_at)
return 1;
return -1;
});
const limit = 200;
let count = 0;
for (; i >= 0 && count < limit; 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 || ev_el.parentElement)
continue;
fragment.appendChild(ev_el);
count++;
}
if (count > 0) {
el.append(fragment);
}
if (count < limit) {
// No more to show, hide the button
find_node("#show-more").classList.add("hide");
}
view_timeline_update_timestamps();
}
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;
const pfp = find_node("img.pfp", el)
if (pfp)
pfp.addEventListener("error", onerror_pfp);
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) {
// XXX if possible update profile pics in a smarter way
// this may be perhaps a micro optimization tho
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);
}
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_SHAKA);
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_USER:
return opts.pubkey && ev.pubkey == opts.pubkey;
case VM_FRIENDS:
if (opts.hide_replys && event_is_reply(ev))
return false;
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);
}
function toggle_hide_replys(el) {
const hide = el.innerText == "Hide Replys";
switch_view(VM_FRIENDS, {hide_replys: hide});
el.innerText = hide ? "Show Replys" : "Hide Replys";
}
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_timeline(model) {
const el = view_get_timeline_el();
el.addEventListener("click", onclick_timeline);
}
function onclick_timeline(ev) {
if (ev.target.matches(".username[data-pubkey]")) {
open_profile(ev.target.dataset.pubkey);
}
}
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) {
find_node("#reply-content").addEventListener("input", oninput_post);
find_node("#dm-post textarea").addEventListener("input", oninput_post);
find_node("button[name='reply']")
.addEventListener("click", onclick_reply);
find_node("button[name='reply-all']")
.addEventListener("click", onclick_reply);
find_node("button[name='send']")
.addEventListener("click", onclick_send);
find_node("button[name='send-dm']")
.addEventListener("click", onclick_send_dm);
}
async function onclick_reply(ev) {
do_send_reply(ev.target.dataset.all == "1");
}
async function onclick_send(ev) {
const el = find_node("#reply-modal");
const pubkey = await get_pubkey();
const el_input = el.querySelector("#reply-content");
let post = {
pubkey,
kind: KIND_NOTE,
created_at: new_creation_time(),
content: el_input.value,
tags: [],
};
post.id = await nostrjs.calculate_id(post);
post = await sign_event(post);
broadcast_event(post);
// Reset UI
el_input.value = "";
trigger_postbox_assess(el_input);
close_modal(el);
/*
const el_cw = document.querySelector("#content-warning-input");
//tags: el_cw.value ? [["content-warning", el_cw.value]] : [],
el_cw.value = "";*/
}
async function onclick_send_dm(ev) {
const pubkey = await get_pubkey();
const el = find_node("#dm-post");
const el_input = el.querySelector("textarea");
const target = view_get_timeline_el().dataset.pubkey;
let post = {
pubkey,
kind: KIND_DM,
created_at: new_creation_time(),
content: await window.nostr.nip04.encrypt(target, el_input.value),
tags: [["p", target]],
};
post.id = await nostrjs.calculate_id(post);
post = await sign_event(post);
broadcast_event(post);
el_input.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);
}
function onclick_any(ev) {
const el = ev.target;
const action = el.getAttribute("action");
switch (action) {
case "sign-in":
signin();
break;
case "open-view":
switch_view(el.dataset.view);
break;
case "close-media":
close_media_preview();
break;
case "close-modal":
close_modal(el);
break;
case "open-profile":
open_profile(el.dataset.pubkey);
break;
case "open-profile-editor":
click_update_profile();
break;
case "show-timeline-new":
show_new();
break;
case "show-timeline-more":
view_timeline_show_more(DAMUS);
break;
case "open-thread":
open_thread(el.dataset.threadId);
break;
case "reply":
send_reply(el.dataset.emoji, el.dataset.to);
break;
case "delete":
delete_post(el.dataset.evid);
break;
case "reply-to":
reply(el.dataset.evid);
break;
case "react-like":
click_toggle_like(el);
break;
case "share":
click_share(el);
break;
case "open-thread":
open_thread(el.dataset.threadId);
break;
case "open-media":
open_media_preview(el.src, el.dataset.type);
break;
case "open-link":
window.open(el.dataset.url, "_blank");
break;
case "open-lud06":
open_lud06(el.dataset.lud06);
break;
case "show-event-json":
on_click_show_event_details(el.dataset.evid);
break;
case "confirm-delete":
delete_post_confirm(el.dataset.evid);
break;
case "mark-all-read":
model_mark_dms_seen(DAMUS);
break;
case "toggle-hide-replys":
toggle_hide_replys(el);
break;
case "new-note":
new_note();
break;
}
}