yosup/js/ui/state.js
Thomas Mathews 7580e8423a Reduce Scope
I am no longer going to support all the features and every NIP. It's too
hard. Thus I am reducing what I support in YoSup. From now on it will
only be a simple client with simple needs that serve me. I will continue
to use Damus on iOS or other clients for sending Zaps or using other
functionality. I do not feel I need to support these features and it has
me competing with other clients such as Snort or Iris, I don't want to
be a clone of them - I want a simple client for my needs: viewing what's
up from people I follow.

Thus I have started removing features and optimizing. Especially for the
very poor internet connection here in Costa Rica, reducing load of
images, content, etc. makes the client much faster and easier to use.

If you feel you are missing features please use the other clients that
have put in a LOT of hard work.
2023-03-22 10:55:08 -07:00

744 lines
20 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;
find_node("#nav > div[data-active]").dataset.active = names[mode].toLowerCase();
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);
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);
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_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);
close_gnav();
}
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("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);
}
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);
/*
const el_cw = document.querySelector("#content-warning-input");
//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);
}
el_cw.value = "";*/
}
/* 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 "toggle-gnav":
toggle_gnav(el);
break;
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();
}
}