yosup/js/model.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

515 lines
13 KiB
JavaScript

/* model_process_event is the main point where events are post-processed from
* a relay. Additionally other side effects happen such as notification checks
* and fetching of unknown pubkey profiles.
*/
function model_process_event(model, relay, ev) {
if (model.all_events[ev.id]) {
return;
}
let fetch_profile = false;
model.all_events[ev.id] = ev;
ev.refs = event_get_tag_refs(ev.tags);
ev.pow = event_calculate_pow(ev);
// Process specific event needs based on it's kind. Not using a map because
// integers can't be used.
let fn;
switch(ev.kind) {
case KIND_NOTE:
case KIND_SHARE:
fetch_profile = true;
break;
case KIND_METADATA:
fn = model_process_event_metadata;
break;
case KIND_CONTACT:
fn = model_process_event_following;
break;
case KIND_DELETE:
fn = model_process_event_deletion;
break;
case KIND_REACTION:
fn = model_process_event_reaction;
break;
case KIND_DM:
fn = model_process_event_dm;
break;
}
if (fn)
fn(model, ev, !!relay);
// Queue event for rendering
model.invalidated.push(ev.id);
// If the processing did not come from a relay, but instead storage then
// let us simply ignore fetching new things.
if (!relay)
return;
// Request new profiles for unseen pubkeys of the event
if (fetch_profile) {
event_get_pubkeys(ev).forEach((pubkey) => {
if (!model_has_profile(model, pubkey)) {
model_que_profile(model, relay, pubkey);
}
});
}
}
function model_get_relay_que(model, relay) {
return map_get(model.relay_que, relay, {
profiles: [],
timestamp: 0,
contacts_init: false,
});
}
function model_que_profile(model, relay, pubkey) {
const que = model_get_relay_que(model, relay);
if (que.profiles.indexOf(pubkey) >= 0)
return;
que.profiles.push(pubkey);
}
function model_clear_profile_que(model, relay, sid) {
const que = model_get_relay_que(model, relay);
//log_debug(`cmp '${que.sid}' vs '${sid}'`);
if (que.sid != sid)
return;
delete que.current;
delete que.sid;
que.timestamp = 0;
log_debug("cleared qued");
}
function model_fetch_next_profile(model, relay) {
const que = model_get_relay_que(model, relay);
// Give up on existing case and add it back to the que
if (que.current) {
if ((new Date().getTime() - que.timestamp) / 1000 > 30) {
que.profiles = que.profiles.concat(que.current);
model.pool.unsubscribe(SID_PROFILES, relay);
log_debug(`(${relay.url}) gave up on ${que.current.length} profiles`);
} else {
return;
}
}
if (que.profiles.length == 0) {
delete que.current;
return;
}
log_debug(`(${relay.url}) has '${que.profiles.length} left profiles to fetch'`);
const set = new Set();
let i = 0;
while (que.profiles.length > 0 && i < 100) {
set.add(que.profiles.shift());
i++;
}
que.timestamp = new Date().getTime();
que.current = Array.from(set);
fetch_profiles(model.pool, relay, que.current);
}
/* model_process_event_profile updates the matching profile with the contents found
* in the event.
*/
function model_process_event_metadata(model, ev, update_view) {
const profile = model_get_profile(model, ev.pubkey);
const evs = model.all_events;
if (profile.evid &&
evs[ev.id].created_at < evs[profile.evid].created_at)
return;
profile.evid = ev.id;
profile.data = safe_parse_json(ev.content, "profile contents");
if (update_view)
view_timeline_update_profiles(model, profile.pubkey);
// 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) {
redraw_my_pfp(model);
}*/
}
function model_has_profile(model, pk) {
return !!model_get_profile(model, pk).evid;
}
function model_get_profile(model, pubkey) {
if (model.profiles.has(pubkey)) {
return model.profiles.get(pubkey);
}
model.profiles.set(pubkey, {
pubkey: pubkey,
evid: "",
relays: [],
data: {},
});
return model.profiles.get(pubkey);
}
function model_process_event_following(model, ev, update_view) {
contacts_process_event(model.contacts, model.pubkey, ev)
// TODO support loading relays that are stored on the initial relay
// I find this wierd since I may never want to do that and only have that
// information provided by the client - to be better understood
// load_our_relays(model.pubkey, model.pool, ev)
}
/* 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
if (ev.created_at > dm.last_viewed) {
dm.new_count++;
// dirty hack but works well
model.dms_need_redraw = true;
}
}
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 time by the client/user
last_viewed: 0,
new_count: 0,
// Notifies the renderer that this dm is out of date
needs_redraw: false,
needs_decryption: false,
});
}
return model.dms.get(target);
}
function model_get_dms_seen(model) {
const obj = {};
for (let item of model.dms) {
const dm = item[1];
obj[dm.pubkey] = dm.last_viewed;
}
return obj;
}
function model_set_dms_seen(model, obj={}) {
for (const pubkey in obj) {
model_get_dm(model, pubkey).last_viewed = obj[pubkey];
}
}
function model_dm_seen(model, target) {
const dm = model_get_dm(model, target);
if (!dm.events[0])
return;
dm.last_viewed = dm.events[0].created_at;
dm.new_count = 0;
dm.needs_redraw = true;
}
function model_mark_dms_seen(model) {
model.dms.forEach((dm) => {
model_dm_seen(model, dm.pubkey);
});
model.dms_need_redraw = true;
}
/* model_process_event_reaction updates the reactions dictionary
*/
function model_process_event_reaction(model, ev, update_view) {
let reaction = event_parse_reaction(ev);
if (!reaction) {
return;
}
if (!model.reactions_to[reaction.e])
model.reactions_to[reaction.e] = new Set();
model.reactions_to[reaction.e].add(ev.id);
if (update_view)
view_timeline_update_reaction(model, ev);
}
/* event_process_deletion updates the list of deleted events. Additionally
* pushes event ids onto the invalidated stack for any found.
*/
function model_process_event_deletion(model, ev, update_view) {
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "e" && tag[1]) {
let evid = tag[1];
model.invalidated.push(evid);
model_remove_reaction(model, evid, update_view);
if (model.deleted[evid])
continue;
let ds = model.deletions[evid] =
(model.deletions[evid] || new Set());
ds.add(ev.id);
}
}
}
function model_remove_reaction(model, evid, update_view) {
// deleted_ev -> target_ev -> original_ev
// Here we want to find the original react event to and remove it from our
// reactions map, then we want to update the element on the page. If the
// server does not clean up events correctly the increment/decrement method
// should work fine in theory.
const target_ev = model.all_events[evid];
if (!target_ev)
return;
const reaction = event_parse_reaction(target_ev);
if (!reaction)
return;
if (model.reactions_to[reaction.e])
model.reactions_to[reaction.e].delete(target_ev.id);
if (update_view)
view_timeline_update_reaction(model, target_ev);
}
function model_is_event_deleted(model, evid) {
// we've already know it's deleted
if (model.deleted[evid])
return model.deleted[evid]
const ev = model.all_events[evid]
if (!ev)
return false
// all deletion events
const ds = model.deletions[ev.id]
if (!ds)
return false
// find valid deletion events
for (const id of ds.keys()) {
const d_ev = model.all_events[id]
if (!d_ev)
continue
// only allow deletes from the user who created it
if (d_ev.pubkey === ev.pubkey) {
model.deleted[ev.id] = d_ev
delete model.deletions[ev.id]
return true
}
}
return false
}
function model_has_event(model, evid) {
return evid in model.all_events
}
function model_events_arr(model) {
const events = model.all_events;
let arr = [];
for (const evid in events) {
const ev = events[evid];
const i = arr_bsearch_insert(arr, ev, event_cmp_created);
arr.splice(i, 0, ev);
}
return arr;
}
function test_model_events_arr() {
const arr = model_events_arr({all_events: {
"c": {name: "c", created_at: 2},
"a": {name: "a", created_at: 0},
"b": {name: "b", created_at: 1},
"e": {name: "e", created_at: 4},
"d": {name: "d", created_at: 3},
}});
let last;
while(arr.length > 0) {
let ev = arr.pop();
log_debug("test:", ev.name, ev.created_at);
if (!last) {
last = ev;
continue;
}
if (ev.created_at > last.created_at) {
log_error(`ev ${ev.name} should be before ${last.name}`);
}
last = ev;
}
}
async function model_save_settings(model) {
function _settings_save(ev, resolve, reject) {
const db = ev.target.result;
const tx = db.transaction("settings", "readwrite");
const store = tx.objectStore("settings");
tx.oncomplete = (ev) => {
db.close();
resolve();
//log_debug("settings saved");
};
tx.onerror = (ev) => {
db.close();
log_error("failed to save events");
reject(ev);
};
store.clear().onsuccess = () => {
store.put({
pubkey: model.pubkey,
notifications_last_viewed: model.notifications.last_viewed,
relays: Array.from(model.relays),
embeds: model.embeds,
dms_seen: model_get_dms_seen(model),
});
};
}
return dbcall(_settings_save);
}
async function model_load_settings(model) {
function _settings_load(ev, resolve, reject) {
const db = ev.target.result;
const tx = db.transaction("settings", "readonly");
const store = tx.objectStore("settings");
const req = store.get(model.pubkey);
req.onsuccess = (ev) => {
const settings = ev.target.result;
if (settings) {
model.notifications.last_viewed = settings.notifications_last_viewed;
if (settings.relays.length)
model.relays = new Set(settings.relays);
model.embeds = settings.embeds ? settings.embeds : "friends";
model_set_dms_seen(model, settings.dms_seen);
}
db.close();
resolve();
log_debug("Successfully loaded events");
}
req.onerror = (ev) => {
db.close();
reject(ev);
log_error("Could not load settings.");
};
}
return dbcall(_settings_load);
}
async function model_save_events(model) {
function _events_save(ev, resolve, reject) {
const db = ev.target.result;
let tx = db.transaction("events", "readwrite");
let store = tx.objectStore("events");
tx.oncomplete = (ev) => {
db.close();
resolve();
//log_debug("saved events!");
};
tx.onerror = (ev) => {
db.close();
log_error("failed to save events");
reject(ev);
};
store.clear().onsuccess = ()=> {
for (const evid in model.all_events) {
store.put(model.all_events[evid]);
}
}
}
return dbcall(_events_save);
}
async function model_load_events(model, fn) {
function _events_load(ev, resolve, reject) {
const db = ev.target.result;
const tx = db.transaction("events", "readonly");
const store = tx.objectStore("events");
const cursor = store.openCursor();
cursor.onsuccess = (ev) => {
var cursor = ev.target.result;
if (cursor) {
fn(cursor.value);
cursor.continue();
} else {
db.close();
resolve();
log_debug("Successfully loaded events");
}
}
cursor.onerror = (ev) => {
db.close();
reject(ev);
log_error("Could not load events.");
};
}
return dbcall(_events_load);
}
function new_model() {
return {
all_events: {}, // our master list of all events
notifications: {
last_viewed: 0, // time since last looking at notifications
count: 0, // the number not seen since last looking
},
profiles: new Map(), // pubkey => profile data
contacts: {
event: null,
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(),
relays: new Set([
"wss://nostr-pub.wellorder.net",
"wss://nostr-relay.wlvs.space",
"wss://nostr.oxtr.dev",
"wss://brb.io",
"wss://sg.qemura.xyz",
"wss://nostr.v0l.io",
"wss://relay.nostr.bg",
"wss://nostr.mom",
]),
embeds: "friends", // friends, everyone
max_depth: 2,
reactions_to: {},
deletions: {},
deleted: {},
pow: 0, // pow difficulty target
};
}