
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.
478 lines
12 KiB
JavaScript
478 lines
12 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;
|
|
}
|
|
|
|
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_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
|
|
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)
|
|
}
|
|
|
|
function event_is_dm(ev, mykey) {
|
|
if (ev.kind != KIND_DM)
|
|
return false;
|
|
if (ev.pubkey != mykey && event_tags_pubkey(ev, mykey))
|
|
return true;
|
|
return ev.pubkey == mykey;
|
|
}
|
|
|
|
/* 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
|
|
const b = model.all_events[dm.last_seen];
|
|
if (!b || b.created_at < ev.created_at) {
|
|
// TODO update notification UI
|
|
dm.new_count++;
|
|
}
|
|
}
|
|
|
|
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 by the client/user
|
|
last_seen: "",
|
|
new_count: 0,
|
|
// Notifies the renderer that this dm is out of date
|
|
needs_redraw: false,
|
|
needs_decryption: false,
|
|
});
|
|
}
|
|
return model.dms.get(target);
|
|
}
|
|
|
|
/* 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,
|
|
});
|
|
};
|
|
}
|
|
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";
|
|
}
|
|
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://relay.damus.io",
|
|
"wss://nostr-relay.wlvs.space",
|
|
"wss://nostr-pub.wellorder.net",
|
|
]),
|
|
embeds: "friends", // friends, everyone
|
|
|
|
max_depth: 2,
|
|
reactions_to: {},
|
|
deletions: {},
|
|
deleted: {},
|
|
pow: 0, // pow difficulty target
|
|
};
|
|
}
|