Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Thomas Mathews
03d7d7b0cc web: revamp mainly done, couple bugs left 2022-12-17 21:13:37 -08:00
Thomas Mathews
f417732942 Got a unified timeline working 2022-12-16 15:15:33 -08:00
Thomas Mathews
00edee1cb3 WIP 2022-12-15 23:34:41 -08:00
16 changed files with 1285 additions and 1011 deletions

View file

@ -137,7 +137,7 @@ button.nav > img.icon {
position: sticky;
top: 0;
z-index: var(--zHeader);
background: var(--clrBg);
backdrop-filter: blur(20px);
}
#view header > label {
padding: 15px;
@ -337,6 +337,7 @@ details.cw summary {
background: rgba(0,0,0,0.4);
opacity: 1;
transition: opacity 0.2s linear;
backdrop-filter: blur(20px);
}
.modal.closed {
opacity: 0;
@ -440,19 +441,11 @@ label[role="profile-nip5"] {
/* Media Preview */
.bg-blur {
backdrop-filter: blur(20px);
position: absolute;
width: 100%;
height: 100%;
z-index: var(--zModal);
}
.modal .media-container {
text-align: center;
position: absolute;
width: 100%;
height: 100%;
z-index: calc(var(--zModal) + 1);
}
.modal .media-container > img {
object-fit: scale-down;

View file

@ -17,6 +17,13 @@
<script defer src="js/bech32.js?v=1"></script>
<script defer src="js/nostr.js?v=7"></script>
<script defer src="js/core.js?v=1"></script>
<script defer src="js/cache.js?v=1"></script>
<script defer src="js/model.js?v=1"></script>
<script defer src="js/filters.js?v=1"></script>
<script defer src="js/contacts.js?v=1"></script>
<script defer src="js/event.js?v=1"></script>
<script defer src="js/unknowns.js?v=1"></script>
<script defer src="js/lib.js?v=1"></script>
<script defer src="js/damus.js?v=92"></script>
</head>
<body>
@ -33,7 +40,7 @@
<button class="icon" role="open-gnav" title="Open Menu" onclick="toggle_gnav(this)">
<img class="icon svg invert" src="icon/logo.svg"/>
</button>
<button class="icon" role="home" title="Home" onclick="switch_view('home')">
<button class="icon" role="home" title="Home" onclick="switch_view('friends')">
<img class="icon svg invert" src="icon/home.svg"/>
</button>
<button class="icon" role="explore" title="Explore" onclick="switch_view('explore')">
@ -56,7 +63,7 @@
<img class="icon svg" title="Damus" src="icon/logo-inverted.svg"/>
</div>
<button role="home" class="nav icon"
title="Home" onclick="switch_view('home')">
title="Home" onclick="switch_view('friends')">
<img class="icon svg inactive" src="icon/home.svg"/>
<img class="icon svg active" src="icon/home-active.svg"/>
</button>
@ -77,7 +84,7 @@
</div>
</div>
<div id="view">
<div id="home-view">
<div>
<header>
<label>Home</label>
</header>
@ -100,31 +107,7 @@
</div>
</div>
</div>
<div class="events"></div>
</div>
<div id="explore-view" class="hide">
<header>
<label>Explore</label>
</header>
<div class="events"></div>
</div>
<div id="notifications-view" class="hide">
<header>
<label>Notifications</label>
</header>
<div class="events"></div>
</div>
<div id="thread-view" class="hide">
<header>
<label>Thread</label>
</header>
<div class="events"></div>
</div>
<div id="profile-view" class="hide">
<header>
<label role="profile-name">Profile</label>
</header>
<div role="profile-info" class="bottom-border">
<div role="profile-info" class="bottom-border hide">
<div class="flex">
<img role="profile-image" class="pfp jumbo" src="" />
<div class="profile-tools">
@ -144,14 +127,18 @@
<p role="profile-desc"></p>
</div>
</div>
<div class="events"></div>
<div class="loading-events">
<div class="loader" title="Loading...">
<img class="dark-invert" src="icon/loader-fragment.svg"/>
</div>
</div>
<div id="timeline" class="events"></div>
</div>
</div>
<div class="flex-fill vertical-hide"></div>
</div>
<div class="modal closed" id="media-preview">
<div class="bg-blur"></div>
<div class="media-container">
<img onclick="close_media_preview()" src=""/>
</div>

89
web/js/cache.js Normal file
View file

@ -0,0 +1,89 @@
function load_events(damus) {
if (!('event_cache' in localStorage))
return {}
const cached = JSON.parse(localStorage.getItem('event_cache'))
return cached.reduce((obj, ev) => {
obj[ev.id] = ev
process_event(damus, ev)
return obj
}, {})
}
function load_cache(damus) {
damus.all_events = load_events(damus)
load_timelines(damus)
}
function save_cache(damus) {
save_events(damus)
save_timelines(damus)
}
function save_events(damus)
{
const keys = Object.keys(damus.all_events)
const MAX_KINDS = {
1: 2000,
0: 2000,
6: 100,
4: 100,
5: 100,
7: 100,
}
let counts = {}
let cached = keys.map((key) => {
const ev = damus.all_events[key]
const {sig, pubkey, content, tags, kind, created_at, id} = ev
return {sig, pubkey, content, tags, kind, created_at, id}
})
cached.sort((a,b) => b.created_at - a.created_at)
cached = cached.reduce((cs, ev) => {
counts[ev.kind] = (counts[ev.kind] || 0)+1
if (counts[ev.kind] < MAX_KINDS[ev.kind])
cs.push(ev)
return cs
}, [])
log_debug('saving all events to local storage', cached.length)
localStorage.setItem('event_cache', JSON.stringify(cached))
}
function save_timelines(damus)
{
const views = Object.keys(damus.views).reduce((obj, view_name) => {
const view = damus.views[view_name]
obj[view_name] = view.events.map(e => e.id).slice(0,100)
return obj
}, {})
localStorage.setItem('views', JSON.stringify(views))
}
function load_timelines(damus)
{
if (!('views' in localStorage))
return
const stored_views = JSON.parse(localStorage.getItem('views'))
for (const view_name of Object.keys(damus.views)) {
const view = damus.views[view_name]
view.events = (stored_views[view_name] || []).reduce((evs, evid) => {
const ev = damus.all_events[evid]
if (ev) evs.push(ev)
return evs
}, [])
}
}
function schedule_save_events(damus)
{
if (damus.save_timer)
clearTimeout(damus.save_timer)
damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000)
}

49
web/js/contacts.js Normal file
View file

@ -0,0 +1,49 @@
function contacts_friend_list(contacts) {
return Array.from(contacts.friends)
}
function contacts_friendosphere(contacts) {
let s = new Set()
let fs = []
for (const friend of contacts.friends.keys()) {
fs.push(friend)
s.add(friend)
}
for (const friend of contacts.friend_of_friends.keys()) {
if (!s.has(friend))
fs.push(friend)
}
return fs
}
function add_contact_if_friend(contacts, ev) {
if (!contact_is_friend(contacts, ev.pubkey))
return
add_friend_contact(contacts, ev)
}
function contact_is_friend(contacts, pk) {
return contacts.friends.has(pk)
}
function add_friend_contact(contacts, contact) {
contacts.friends.add(contact.pubkey)
for (const tag of contact.tags) {
if (tag.length >= 2 && tag[0] == "p") {
if (!contact_is_friend(contacts, tag[1]))
contacts.friend_of_friends.add(tag[1])
}
}
}
function load_our_contacts(contacts, our_pubkey, ev) {
if (ev.pubkey !== our_pubkey)
return
contacts.event = ev
for (const tag of ev.tags) {
if (tag.length > 1 && tag[0] === "p") {
contacts.friends.add(tag[1])
}
}
}

View file

@ -1,3 +1,8 @@
const KIND_METADATA = 0;
const KIND_NOTE = 1;
const KIND_SERVER = 2;
const KIND_REACTION = 7;
function get_local_state(key) {
if (DAMUS[key] != null)
return DAMUS[key]
@ -197,17 +202,13 @@ function gather_reply_tags(pubkey, from) {
return tags
}
function get_tag_event(tag)
{
function get_tag_event(tag) {
if (tag.length < 2)
return null
if (tag[0] === "e")
return DAMUS.all_events[tag[1]]
if (tag[0] === "p")
return DAMUS.all_events[DAMUS.profile_events[tag[1]]]
return null
}

View file

@ -1,9 +1,12 @@
let DAMUS
const STANDARD_KINDS = [1, 42, 5, 6, 7];
const BOOTSTRAP_RELAYS = [
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr-pub.wellorder.net"
"wss://nostr.rdfriedl.com",
//"wss://relay.damus.io",
//"wss://nostr-relay.wlvs.space",
//"wss://nostr-pub.wellorder.net"
]
const DEFAULT_PROFILE = {
@ -30,869 +33,154 @@ async function damus_web_init() {
}
async function damus_web_init_ready() {
const model = init_home_model()
const model = new_model()
DAMUS = model
model.pubkey = await get_pubkey()
if (!model.pubkey)
return
const {RelayPool} = nostrjs
const pool = RelayPool(BOOTSTRAP_RELAYS)
const now = new_creation_time()
const ids = {
comments: "comments",//uuidv4(),
profiles: "profiles",//uuidv4(),
explore: "explore",//uuidv4(),
refevents: "refevents",//uuidv4(),
account: "account",//uuidv4(),
home: "home",//uuidv4(),
contacts: "contacts",//uuidv4(),
comments: "comments",//uuidv4(),
profiles: "profiles",//uuidv4(),
explore: "explore",//uuidv4(),
refevents: "refevents",//uuidv4(),
account: "account",//uuidv4(),
home: "home",//uuidv4(),
contacts: "contacts",//uuidv4(),
notifications: "notifications",//uuidv4(),
unknowns: "unknowns",//uuidv4(),
dms: "dms",//uuidv4(),
unknowns: "unknowns",//uuidv4(),
dms: "dms",//uuidv4(),
}
model.ids = ids
model.pool = pool
load_cache(model)
model.view_el = document.querySelector("#view")
//load_cache(model)
switch_view('home')
view_timeline_apply_mode(model, VM_FRIENDS);
document.addEventListener('visibilitychange', () => {
update_title(model)
})
pool.on('open', (relay) => {
//let authors = followers
// TODO: fetch contact list
log_debug("relay connected", relay.url)
if (!model.done_init[relay]) {
send_initial_filters(ids.account, model.pubkey, relay)
} else {
send_home_filters(model, relay)
}
//relay.subscribe(comments_id, {kinds: [1,42], limit: 100})
});
pool.on('event', (relay, sub_id, ev) => {
handle_home_event(model, relay, sub_id, ev)
})
pool.on('notice', (relay, notice) => {
log_debug("NOTICE", relay, notice)
})
pool.on('eose', async (relay, sub_id) => {
if (sub_id === ids.home) {
//log_debug("got home EOSE from %s", relay.url)
const events = model.views.home.events
handle_comments_loaded(ids, model, events, relay)
} else if (sub_id === ids.profiles) {
//log_debug("got profiles EOSE from %s", relay.url)
const view = get_current_view()
handle_profiles_loaded(ids, model, view, relay)
} else if (sub_id === ids.unknowns) {
model.pool.unsubscribe(ids.unknowns, relay)
}
})
update_timestamps();
pool.on("open", on_pool_open);
pool.on("event", on_pool_event);
pool.on("notice", on_pool_notice);
pool.on("eose", on_pool_eose);
pool.on("ok", on_pool_ok);
return pool
}
function insert_event_sorted(evs, new_ev) {
for (let i = 0; i < evs.length; i++) {
const ev = evs[i]
if (new_ev.id === ev.id) {
return false
}
if (new_ev.created_at > ev.created_at) {
evs.splice(i, 0, new_ev)
return true
}
}
evs.push(new_ev)
return true
function update_timestamps() {
setTimeout(() => {
update_timestamps();
view_timeline_update_timestamps();
}, 60 * 1000);
}
function init_contacts() {
return {
event: null,
friends: new Set(),
friend_of_friends: new Set(),
}
}
function init_timeline(name) {
return {
name,
events: [],
rendered: new Set(),
depths: {},
expanded: new Set(),
}
}
function init_home_model() {
return {
done_init: {},
notifications: 0,
max_depth: 2,
all_events: {},
reactions_to: {},
chatrooms: {},
unknown_ids: {},
unknown_pks: {},
deletions: {},
but_wait_theres_more: 0,
cw_open: {},
views: {
home: init_timeline('home'),
explore: {
...init_timeline('explore'),
seen: new Set(),
},
notifications: {
...init_timeline('notifications'),
max_depth: 1,
},
profile: init_timeline('profile'),
thread: init_timeline('thread'),
},
pow: 0, // pow difficulty target
deleted: {},
profiles: {},
profile_events: {},
last_event_of_kind: {},
contacts: init_contacts()
}
}
function notice_chatroom(state, id) {
if (!state.chatrooms[id])
state.chatrooms[id] = {}
}
function process_reaction_event(model, ev) {
if (!is_valid_reaction_content(ev.content))
return
let last = {}
for (const tag of ev.tags) {
if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"))
last[tag[0]] = tag[1]
}
if (last.e) {
model.reactions_to[last.e] = model.reactions_to[last.e] || new Set()
model.reactions_to[last.e].add(ev.id)
}
}
function process_chatroom_event(model, ev) {
model.chatrooms[ev.id] = safe_parse_json(ev.content,
"chatroom create event");
}
function process_contact_event(model, ev) {
load_our_contacts(model.contacts, model.pubkey, ev)
load_our_relays(model.pubkey, model.pool, ev)
add_contact_if_friend(model.contacts, ev)
}
function process_json_content(ev) {
ev.json_content = safe_parse_json(ev.content, "event json_content");
}
function process_deletion_event(model, ev) {
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "e") {
const evid = tag[1]
// we've already recorded this one as a valid deleted
// event we can just ignore it
if (model.deleted[evid])
continue
let ds = model.deletions[evid] =
(model.deletions[evid] || new Set())
// add the deletion event id to the deletion set of
// this event we will use this to determine if this
// event is valid later in case we don't have the
// deleted event yet.
ds.add(ev.id)
}
}
}
// TODO rename is_deleted to is_event_deleted
function is_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
log_debug("received deletion for", ev)
// clean up deletion data that we don't need anymore
delete model.deletions[ev.id]
return true
} else {
log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`)
}
}
return false
}
function has_profile(damus, pk) {
return pk in damus.profiles
}
function has_event(damus, evid) {
return evid in damus.all_events
}
function make_unk(hint, ev) {
const attempts = 0
const parent_created = ev.created_at
if (hint && hint !== "")
return {attempts, hint: hint.trim().toLowerCase(), parent_created}
return {attempts, parent_created}
}
function notice_unknown_ids(damus, ev) {
// make sure this event itself is removed from unknowns
if (ev.kind === 0)
delete damus.unknown_pks[ev.pubkey]
delete damus.unknown_ids[ev.id]
let got_some = false
for (const tag of ev.tags) {
if (tag.length >= 2) {
if (tag[0] === "p") {
const pk = tag[1]
if (!has_profile(damus, pk) && is_valid_id(pk)) {
got_some = true
damus.unknown_pks[pk] = make_unk(tag[2], ev)
}
} else if (tag[0] === "e") {
const evid = tag[1]
if (!has_event(damus, evid) && is_valid_id(evid)) {
got_some = true
damus.unknown_ids[evid] = make_unk(tag[2], ev)
}
}
}
}
return got_some
}
function gather_unknown_hints(damus, pks, evids)
{
let relays = new Set()
for (const pk of pks) {
const unk = damus.unknown_pks[pk]
if (unk && unk.hint && unk.hint !== "")
relays.add(unk.hint)
}
for (const evid of evids) {
const unk = damus.unknown_ids[evid]
if (unk && unk.hint && unk.hint !== "")
relays.add(unk.hint)
}
return Array.from(relays)
}
function get_non_expired_unknowns(unks, type)
{
const MAX_ATTEMPTS = 2
function sort_parent_created(a_id, b_id) {
const a = unks[a_id]
const b = unks[b_id]
return b.parent_created - a.parent_created
}
let new_expired = 0
const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => {
if (ids.length >= 255)
return ids
const unk = unks[unk_id]
if (unk.attempts >= MAX_ATTEMPTS) {
if (!unk.expired) {
unk.expired = true
new_expired++
}
return ids
}
unk.attempts++
ids.push(unk_id)
return ids
}, [])
if (new_expired !== 0)
log_debug("Gave up looking for %d %s", new_expired, type)
return ids
}
function fetch_unknown_events(damus)
{
let filters = []
const pks = get_non_expired_unknowns(damus.unknown_pks, 'profiles')
const evids = get_non_expired_unknowns(damus.unknown_ids, 'events')
const relays = gather_unknown_hints(damus, pks, evids)
for (const relay of relays) {
if (!damus.pool.has(relay)) {
log_debug("adding %s to relays to fetch unknown events", relay)
damus.pool.add(relay)
}
}
if (evids.length !== 0) {
const unk_kinds = [1,5,6,7,40,42]
filters.push({ids: evids, kinds: unk_kinds})
filters.push({"#e": evids, kinds: [1,42], limit: 100})
}
if (pks.length !== 0)
filters.push({authors: pks, kinds:[0]})
if (filters.length === 0)
return
log_debug("fetching unknowns", filters)
damus.pool.subscribe('unknowns', filters)
}
function schedule_unknown_refetch(damus)
{
const INTERVAL = 5000
if (!damus.unknown_timer) {
log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000)
damus.unknown_timer = setTimeout(() => {
fetch_unknown_events(damus)
setTimeout(() => {
delete damus.unknown_timer
if (damus.but_wait_theres_more > 0) {
damus.but_wait_theres_more = 0
schedule_unknown_refetch(damus)
}
}, INTERVAL)
}, INTERVAL)
fetch_unknown_events(damus)
function on_pool_open(relay) {
log_info("opened relay", relay);
const model = DAMUS;
// We check the cache if we have init anything, if not we do our inital
// otherwise we do a get since last
if (!model.done_init[relay]) {
// if we have not init'd the relay simply get our initial filters
relay.subscribe(model.ids.account, filter_new_initial(model.pk));
} else {
damus.but_wait_theres_more++
// otherwise let's get everythign since the last time
model_subscribe_defaults(model, relay);
}
}
function process_event(damus, ev)
{
ev.refs = determine_event_refs(ev.tags)
const notified = was_pubkey_notified(damus.pubkey, ev)
ev.notified = notified
const got_some_unknowns = notice_unknown_ids(damus, ev)
if (got_some_unknowns)
schedule_unknown_refetch(damus)
ev.pow = calculate_pow(ev)
if (ev.kind === 7)
process_reaction_event(damus, ev)
else if (ev.kind === 42 && ev.refs && ev.refs.root)
notice_chatroom(damus, ev.refs.root)
else if (ev.kind === 40)
process_chatroom_event(damus, ev)
else if (ev.kind === 5)
process_deletion_event(damus, ev)
else if (ev.kind === 0)
process_profile_event(damus, ev)
else if (ev.kind === 3)
process_contact_event(damus, ev)
const last_notified = get_local_state('last_notified_date')
if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
set_local_state('last_notified_date', new Date().getTime())
damus.notifications++
update_title(damus)
}
function on_pool_notice(relay, notice) {
log_info("notice", relay.url, notice);
// DO NOTHING
}
function was_pubkey_notified(pubkey, ev)
{
if (!(ev.kind === 1 || ev.kind === 42))
return false
if (ev.pubkey === pubkey)
return false
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
return true
}
return false
}
function should_add_to_notification_timeline(our_pk, contacts, ev, pow)
{
if (!should_add_to_timeline(ev))
return false
// TODO: add items that don't pass spam filter to "message requests"
// Then we will need a way to whitelist people as an alternative to
// following them
return passes_spam_filter(contacts, ev, pow)
}
function should_add_to_explore_timeline(contacts, view, ev, pow)
{
if (!should_add_to_timeline(ev))
return false
if (view.seen.has(ev.pubkey))
return false
// hide friends for 0-pow situations
if (pow === 0 && contacts.friends.has(ev.pubkey))
return false
return passes_spam_filter(contacts, ev, pow)
}
function handle_redraw_logic(model, view_name)
{
const view = model.views[view_name]
if (view.redraw_timer)
clearTimeout(view.redraw_timer)
view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600)
}
function schedule_save_events(damus)
{
if (damus.save_timer)
clearTimeout(damus.save_timer)
damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000)
}
function calculate_last_of_kind(evs) {
const now_sec = new Date().getTime() / 1000
return Object.keys(evs).reduce((obj, evid) => {
const ev = evs[evid]
if (!is_valid_time(now_sec, ev.created_at))
return obj
const prev = obj[ev.kind] || 0
obj[ev.kind] = get_since_time(max(ev.created_at, prev))
return obj
}, {})
}
function load_our_relays(our_pubkey, pool, ev) {
if (ev.pubkey != our_pubkey)
return
let relays
try {
relays = JSON.parse(ev.content)
} catch (e) {
log_error("error loading relays", e)
return
}
for (const relay of Object.keys(relays)) {
if (!pool.has(relay)) {
log_debug("adding relay", relay)
pool.add(relay)
}
}
}
function load_our_contacts(contacts, our_pubkey, ev) {
if (ev.pubkey !== our_pubkey)
return
contacts.event = ev
for (const tag of ev.tags) {
if (tag.length > 1 && tag[0] === "p") {
contacts.friends.add(tag[1])
}
}
}
function load_events(damus) {
if (!('event_cache' in localStorage))
return {}
const cached = JSON.parse(localStorage.getItem('event_cache'))
return cached.reduce((obj, ev) => {
obj[ev.id] = ev
process_event(damus, ev)
return obj
}, {})
}
function load_cache(damus) {
damus.all_events = load_events(damus)
load_timelines(damus)
}
function save_cache(damus) {
save_events(damus)
save_timelines(damus)
}
function save_events(damus)
{
const keys = Object.keys(damus.all_events)
const MAX_KINDS = {
1: 2000,
0: 2000,
6: 100,
4: 100,
5: 100,
7: 100,
}
let counts = {}
let cached = keys.map((key) => {
const ev = damus.all_events[key]
const {sig, pubkey, content, tags, kind, created_at, id} = ev
return {sig, pubkey, content, tags, kind, created_at, id}
})
cached.sort((a,b) => b.created_at - a.created_at)
cached = cached.reduce((cs, ev) => {
counts[ev.kind] = (counts[ev.kind] || 0)+1
if (counts[ev.kind] < MAX_KINDS[ev.kind])
cs.push(ev)
return cs
}, [])
log_debug('saving all events to local storage', cached.length)
localStorage.setItem('event_cache', JSON.stringify(cached))
}
function save_timelines(damus)
{
const views = Object.keys(damus.views).reduce((obj, view_name) => {
const view = damus.views[view_name]
obj[view_name] = view.events.map(e => e.id).slice(0,100)
return obj
}, {})
localStorage.setItem('views', JSON.stringify(views))
}
function load_timelines(damus)
{
if (!('views' in localStorage))
return
const stored_views = JSON.parse(localStorage.getItem('views'))
for (const view_name of Object.keys(damus.views)) {
const view = damus.views[view_name]
view.events = (stored_views[view_name] || []).reduce((evs, evid) => {
const ev = damus.all_events[evid]
if (ev) evs.push(ev)
return evs
}, [])
}
}
function handle_home_event(model, relay, sub_id, ev) {
const ids = model.ids
// ignore duplicates
if (!has_event(model, ev.id)) {
model.all_events[ev.id] = ev
process_event(model, ev)
schedule_save_events(model)
}
ev = model.all_events[ev.id]
let is_new = true
// on_pool_eose occurs when all storage from a relay has been sent to the
// client.
async function on_pool_eose(relay, sub_id) {
//console.info("eose", relay.url, sub_id);
const model = DAMUS;
const { ids, pool } = model;
switch (sub_id) {
case model.ids.explore:
const view = model.views.explore
// show more things in explore timeline
if (should_add_to_explore_timeline(model.contacts, view, ev, model.pow)) {
view.seen.add(ev.pubkey)
is_new = insert_event_sorted(view.events, ev)
}
if (is_new)
handle_redraw_logic(model, 'explore')
break;
case model.ids.notifications:
if (should_add_to_notification_timeline(model.pubkey, model.contacts, ev, model.pow))
is_new = insert_event_sorted(model.views.notifications.events, ev)
if (is_new)
handle_redraw_logic(model, 'notifications')
break;
case model.ids.home:
if (should_add_to_timeline(ev))
is_new = insert_event_sorted(model.views.home.events, ev)
if (is_new)
handle_redraw_logic(model, 'home')
break;
case model.ids.account:
switch (ev.kind) {
case 3:
model.done_init[relay] = true
model.pool.unsubscribe(model.ids.account, relay)
send_home_filters(model, relay)
break
}
break
case model.ids.profiles:
break
case ids.home:
const events = model_events_arr(model);
// TODO filter out events to friends of friends
on_eose_comments(ids, model, events, relay)
pool.unsubscribe(ids.home, relay);
break;
case ids.profiles:
model.pool.unsubscribe(ids.profiles, relay);
on_eose_profiles(ids, model, relay)
break;
case ids.unknown:
pool.unsubscribe(ids.unknowns, relay);
break;
}
}
function process_profile_event(model, ev) {
const prev_ev = model.all_events[model.profile_events[ev.pubkey]]
if (prev_ev && prev_ev.created_at > ev.created_at)
return
function on_pool_ok(relay) {
console.log("OK", arguments);
}
model.profile_events[ev.pubkey] = ev.id
try {
model.profiles[ev.pubkey] = JSON.parse(ev.content)
} catch(e) {
log_debug("failed to parse profile contents", ev)
function on_pool_event(relay, sub_id, ev) {
const model = DAMUS;
const { ids, pool } = model;
// Process event and apply side effects
if (!model.all_events[ev.id]) {
model.all_events[ev.id] = ev;
model_process_event(model, ev);
// schedule_save_events(model);
}
// Update subscriptions
switch (sub_id) {
case ids.account:
model.done_init[relay] = true;
pool.unsubscribe(ids.account, relay);
model_subscribe_defaults(model, relay);
break;
}
}
function send_initial_filters(account_id, pubkey, relay) {
const filter = {authors: [pubkey], kinds: [3], limit: 1}
//console.log("sending initial filter", filter)
relay.subscribe(account_id, filter)
}
function send_home_filters(model, relay) {
const ids = model.ids
const friends = contacts_friend_list(model.contacts)
friends.push(model.pubkey)
const contacts_filter = {kinds: [0], authors: friends}
const dms_filter = {kinds: [4], "#p": [ model.pubkey ], limit: 100}
const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 100}
const standard_kinds = [1,42,5,6,7]
const home_filter = {kinds: standard_kinds, authors: friends, limit: 500}
// TODO: include pow+fof spam filtering in notifications query
const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100}
let home_filters = [home_filter]
let notifications_filters = [notifications_filter]
let contacts_filters = [contacts_filter]
let dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = {}
if (relay) {
last_of_kind =
model.last_event_of_kind[relay] =
model.last_event_of_kind[relay] || calculate_last_of_kind(model.all_events);
log_debug("last_of_kind", last_of_kind)
}
update_filters_with_since(last_of_kind, home_filters)
update_filters_with_since(last_of_kind, contacts_filters)
update_filters_with_since(last_of_kind, notifications_filters)
update_filters_with_since(last_of_kind, dms_filters)
const subto = relay? [relay] : undefined
model.pool.subscribe(ids.home, home_filters, subto)
model.pool.subscribe(ids.contacts, contacts_filters, subto)
model.pool.subscribe(ids.notifications, notifications_filters, subto)
model.pool.subscribe(ids.dms, dms_filters, subto)
}
function update_filter_with_since(last_of_kind, filter) {
const kinds = filter.kinds || []
let initial = null
let earliest = kinds.reduce((earliest, kind) => {
const last_created_at = last_of_kind[kind]
let since = get_since_time(last_created_at)
if (!earliest) {
if (since === null)
return null
return since
}
if (since === null)
return earliest
return since < earliest ? since : earliest
}, initial)
if (earliest)
filter.since = earliest;
}
function update_filters_with_since(last_of_kind, filters) {
for (const filter of filters) {
update_filter_with_since(last_of_kind, filter)
}
}
function handle_profiles_loaded(ids, model, view, relay) {
// stop asking for profiles
model.pool.unsubscribe(ids.profiles, relay)
//redraw_events(model, view)
redraw_my_pfp(model)
const prefix = difficulty_to_prefix(model.pow)
const fofs = Array.from(model.contacts.friend_of_friends)
const standard_kinds = [1,42,5,6,7]
let pow_filter = {kinds: standard_kinds, limit: 50}
function on_eose_profiles(ids, model, relay) {
const prefix = difficulty_to_prefix(model.pow);
const fofs = Array.from(model.contacts.friend_of_friends);
const standard_kinds = [1,42,5,6,7];
let pow_filter = {kinds: standard_kinds, limit: 50};
if (model.pow > 0)
pow_filter.ids = [ prefix ]
let explore_filters = [ pow_filter ]
if (fofs.length > 0) {
explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50})
}
model.pool.subscribe(ids.explore, explore_filters, relay)
pow_filter.ids = [ prefix ];
let explore_filters = [ pow_filter ];
if (fofs.length > 0)
explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50});
model.pool.subscribe(ids.explore, explore_filters, relay);
}
// load profiles after comment notes are loaded
function handle_comments_loaded(ids, model, events, relay) {
function on_eose_comments(ids, model, events, relay) {
const pubkeys = events.reduce((s, ev) => {
s.add(ev.pubkey)
s.add(ev.pubkey);
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "p") {
if (!model.profile_events[tag[1]])
s.add(tag[1])
s.add(tag[1]);
}
}
return s
}, new Set())
return s;
}, new Set());
const authors = Array.from(pubkeys)
// load profiles and noticed chatrooms
const profile_filter = {kinds: [0,3], authors: authors}
let filters = []
const profile_filter = {kinds: [0,3], authors: authors};
let filters = [];
if (authors.length > 0)
filters.push(profile_filter)
filters.push(profile_filter);
if (filters.length === 0) {
log_debug("No profiles filters to request...")
//log_debug("No profiles filters to request...")
return
}
//console.log("subscribe", profiles_id, filter, relay)
log_debug("subscribing to profiles on %s", relay.url)
//log_debug("subscribing to profiles on %s", relay.url)
model.pool.subscribe(ids.profiles, filters, relay)
}
function determine_event_refs_positionally(pubkeys, ids)
{
if (ids.length === 1)
return {root: ids[0], reply: ids[0], pubkeys}
else if (ids.length >= 2)
return {root: ids[0], reply: ids[1], pubkeys}
return {pubkeys}
}
function determine_event_refs(tags) {
let positional_ids = []
let pubkeys = []
let root
let reply
let i = 0
for (const tag of tags) {
if (tag.length >= 4 && tag[0] == "e") {
positional_ids.push(tag[1])
if (tag[3] === "root") {
root = tag[1]
} else if (tag[3] === "reply") {
reply = tag[1]
}
} else if (tag.length >= 2 && tag[0] == "e") {
positional_ids.push(tag[1])
} else if (tag.length >= 2 && tag[0] == "p") {
pubkeys.push(tag[1])
}
i++
}
if (!(root && reply) && positional_ids.length > 0)
return determine_event_refs_positionally(pubkeys, positional_ids)
/*
if (reply && !root)
root = reply
*/
return {root, reply, pubkeys}
}
function contacts_friend_list(contacts) {
return Array.from(contacts.friends)
}
function contacts_friendosphere(contacts) {
let s = new Set()
let fs = []
for (const friend of contacts.friends.keys()) {
fs.push(friend)
s.add(friend)
}
for (const friend of contacts.friend_of_friends.keys()) {
if (!s.has(friend))
fs.push(friend)
}
return fs
}
function add_contact_if_friend(contacts, ev) {
if (!contact_is_friend(contacts, ev.pubkey))
return
add_friend_contact(contacts, ev)
}
function contact_is_friend(contacts, pk) {
return contacts.friends.has(pk)
}
function add_friend_contact(contacts, contact) {
contacts.friends.add(contact.pubkey)
for (const tag of contact.tags) {
if (tag.length >= 2 && tag[0] == "p") {
if (!contact_is_friend(contacts, tag[1]))
contacts.friend_of_friends.add(tag[1])
}
}
}

122
web/js/event.js Normal file
View file

@ -0,0 +1,122 @@
/* EVENT */
/* event_refs_pubkey checks if the event (ev) is directed at the public key
* (pubkey) by checking its type, tags, and key.
*/
function event_refs_pubkey(ev, pubkey) {
if (!(ev.kind === 1 || ev.kind === 42))
return false
if (ev.pubkey === pubkey)
return false
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
return true
}
return false
}
function event_calculate_pow(ev) {
const id_bits = leading_zero_bits(ev.id)
for (const tag of ev.tags) {
if (tag.length >= 3 && tag[0] === "nonce") {
const target = +tag[2]
if (isNaN(target))
return 0
// if our nonce target is smaller than the difficulty,
// then we use the nonce target as the actual difficulty
return min(target, id_bits)
}
}
// not a valid pow if we don't have a difficulty target
return 0
}
/* event_can_reply returns a boolean value based on if you can "reply" to the
* event in the manner of a chat.
*/
function event_can_reply(ev) {
return ev.kind === 1 || ev.kind === 42
}
/* event_is_timeline returns a boolean based on if the event should be rendered
* in a GUI
*/
function event_is_timeline(ev) {
return ev.kind === 1 || ev.kind === 42 || ev.kind === 6
}
function event_get_tag_refs(tags) {
let ids = []
let pubkeys = []
let root, reply
for (const tag of tags) {
if (tag.length >= 4 && tag[0] == "e") {
ids.push(tag[1])
if (tag[3] === "root") {
root = tag[1]
} else if (tag[3] === "reply") {
reply = tag[1]
}
} else if (tag.length >= 2 && tag[0] == "e") {
ids.push(tag[1])
} else if (tag.length >= 2 && tag[0] == "p") {
pubkeys.push(tag[1])
}
}
if (!(root && reply) && ids.length > 0) {
if (ids.length === 1)
return {root: ids[0], reply: ids[0], pubkeys}
else if (ids.length >= 2)
return {root: ids[0], reply: ids[1], pubkeys}
return {pubkeys}
}
return {root, reply, pubkeys}
}
function event_is_spam(ev, contacts, pow) {
if (contacts.friend_of_friends.has(ev.pubkey))
return true
return ev.pow >= pow
}
function event_cmp_created(a, b) {
if (a.created_at > b.created_at)
return 1;
if (a.created_at < b.created_at)
return -1;
return 0;
}
/* event_refs_event checks if event A references event B in its tags.
*/
function event_refs_event(a, b) {
for (const tag of a.tags) {
if (tag.length >= 2 && tag[0] === "e" && tag[1] == b.id)
return true;
}
return false;
}
function event_get_last_tags(ev) {
let o = {};
for (const tag of ev.tags) {
if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"))
o[tag[0]] = tag[1];
}
return o;
}
/* event_get_reacted_to returns the reacted to event & pubkey (e, p). Returns
* undefined if invalid or incomplete.
*/
function event_parse_reaction(ev) {
if (!is_valid_reaction_content(ev.content) || ev.kind != 7)
return;
const o = event_get_last_tags(ev);
if (o["e"] && o["p"]) {
return o;
}
}

106
web/js/filters.js Normal file
View file

@ -0,0 +1,106 @@
function filters_subscribe(filters, pool, relays=undefined) {
for (const key in filters) {
pool.subscribe(key, filters[key], relays);
}
}
function filters_new_default(model) {
const { pubkey, ids, contacts } = model;
const friends = contacts_friend_list(contacts);
friends.push(pubkey);
const f = {};
f[ids.home] = filters_new_friends(friends);
f[ids.contacts] = filters_new_contacts(friends);
f[ids.dms] = filters_new_dms(pubkey);
f[ids.notifications] = filters_new_notifications(pubkey);
return f;
}
function filters_new_default_since(model, cache) {
const filters = filters_new_default(model);
for (const key in filters) {
filters[key] = filters_set_since(filters[key], cache);
}
return filters;
}
function filter_new_initial(pubkey) {
return {
authors: [pubkey],
kinds: [3],
limit: 1,
};
}
function filters_new_contacts(friends) {
return [{
kinds: [0],
authors: friends,
}]
}
function filters_new_dms(pubkey, limit=100) {
return [
{ // dms we sent
kinds: [4],
limit,
authors: [pubkey],
}, { // replys to us
kinds: [4],
limit: limit,
"#p": [pubkey],
}]
}
function filters_new_friends(friends, limit=500) {
return [{
kinds: STANDARD_KINDS,
authors: friends,
limit,
}]
}
function filters_new_notifications(pubkey, limit=100) {
return [{
kinds: STANDARD_KINDS,
"#p": [pubkey],
limit,
}]
}
function filters_set_since(filters=[], cache={}) {
filters.forEach((filter) => {
const since = get_earliest_since_time(filter.kinds, cache)
delete filter.since;
if (since) filter.since = since;
});
return filters
}
function get_earliest_since_time(kinds=[], cache={}) {
const earliest = kinds.reduce((a, kind) => {
const b = get_since_time(cache[kind]);
if (!a) {
return b;
}
return b < a ? b : a;
}, undefined);
return earliest;
}
/* calculate_last_of_kind returns a map of kinds to time, where time is the
* last time it saw that kind.
*/
function calculate_last_of_kind(evs) {
const now_sec = new Date().getTime() / 1000
return Object.keys(evs).reduce((obj, evid) => {
const ev = evs[evid]
if (!is_valid_time(now_sec, ev.created_at))
return obj
const prev = obj[ev.kind] || 0
obj[ev.kind] = get_since_time(max(ev.created_at, prev))
return obj
}, {})
}

91
web/js/lib.js Normal file
View file

@ -0,0 +1,91 @@
function load_our_relays(our_pubkey, pool, ev) {
if (ev.pubkey != our_pubkey)
return
let relays
try {
relays = JSON.parse(ev.content)
} catch (e) {
log_error("error loading relays", e)
return
}
for (const relay of Object.keys(relays)) {
if (!pool.has(relay)) {
log_debug("adding relay", relay)
pool.add(relay)
}
}
}
function notice_chatroom(state, id) {
if (!state.chatrooms[id])
state.chatrooms[id] = {}
}
function should_add_to_notification_timeline(our_pk, contacts, ev, pow)
{
if (!should_add_to_timeline(ev))
return false
// TODO: add items that don't pass spam filter to "message requests"
// Then we will need a way to whitelist people as an alternative to
// following them
return passes_spam_filter(contacts, ev, pow)
}
function should_add_to_explore_timeline(contacts, view, ev, pow)
{
if (!should_add_to_timeline(ev))
return false
if (view.seen.has(ev.pubkey))
return false
// hide friends for 0-pow situations
if (pow === 0 && contacts.friends.has(ev.pubkey))
return false
return passes_spam_filter(contacts, ev, pow)
}
function handle_redraw_logic(model, view_name)
{
const view = model.views[view_name]
if (view.redraw_timer)
clearTimeout(view.redraw_timer)
view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600)
}
/* DEPRECATED */
function is_deleted(model, evid) {
log_warn("is_deleted deprecated, use model_is_event_deleted");
return model_is_event_deleted(model, evid);
}
function process_event(model, ev) {
log_warn("process_event deprecated, use event_process");
return model_process_event(model, ev);
}
function calculate_pow(ev) {
log_warn("calculate_pow deprecated, use event_calculate_pow");
return event_calculate_pow(ev);
}
function insert_event_sorted(evs, new_ev) {
log_warn("insert_event_sorted deprecated, use events_insert_sorted");
events_insert_sorted(evs, new_ev);
}
function can_reply(ev) {
log_warn("can_reply is deprecated, use event_can_reply");
return event_can_reply(ev);
}
function should_add_to_timeline(ev) {
// TODO rename should_add_to_timeline to is_timeline_event
log_warn("should_add_to_timeline is deprecated, use event_is_timeline");
return event_is_timeline(ev);
}
function passes_spam_filter(contacts, ev, pow) {
log_warn("passes_spam_filter deprecated, use event_is_spam");
return !event_is_spam(ev, contacts, pow);
}

266
web/js/model.js Normal file
View file

@ -0,0 +1,266 @@
/* 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, ev) {
ev.refs = event_get_tag_refs(ev.tags);
ev.pow = event_calculate_pow(ev);
// TODO this doesn't actually work because of async nature
ev.is_spam = !event_is_spam(ev, model.contacts, model.pow);
// 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 0:
fn = model_process_event_profile;
break;
case 3:
fn = model_process_event_contact;
break;
case 5:
fn = model_process_event_deletion;
break;
case 7:
fn = model_process_event_reaction;
break;
}
if (fn)
fn(model, ev);
// not handling chatrooms for now
// else if (ev.kind === 42 && ev.refs && ev.refs.root)
// notice_chatroom(damus, ev.refs.root)
// else if (ev.kind === 40)
// event_process_chatroom(damus, ev)
// check if the event that just came in should notify the user and is newer
// than the last recorded notification event, if it is notify
const notify_user = event_refs_pubkey(ev, model.pubkey)
const last_notified = get_local_state('last_notified_date')
ev.notified = notify_user;
if (notify_user && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
set_local_state('last_notified_date', new Date().getTime());
model.notifications++;
update_title(model);
}
// If we find some unknown ids lets schedule their subscription for info
if (model_event_has_unknown_ids(model, ev))
schedule_unknown_refetch(model);
// Refresh timeline
model.invalidated.push(ev.id);
clearTimeout(inv_timer);
inv_timer = setTimeout(() => {
view_timeline_update(model);
}, 1000);
}
let inv_timer;
//function process_chatroom_event(model, ev) {
// model.chatrooms[ev.id] = safe_parse_json(ev.content,
// "chatroom create event");
//}
/* model_process_event_profile updates the matching profile with the contents found
* in the event.
*/
function model_process_event_profile(model, ev) {
const prev_ev = model.all_events[model.profile_events[ev.pubkey]]
if (prev_ev && prev_ev.created_at > ev.created_at)
return
model.profile_events[ev.pubkey] = ev.id
model.profiles[ev.pubkey] = safe_parse_json(ev.content, "profile contents")
view_timeline_update_profiles(model, ev);
}
/* model_process_event_reaction updates the reactions dictionary
*/
function model_process_event_reaction(model, ev) {
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);
view_timeline_update_reaction(model, ev);
}
function model_process_event_contact(model, ev) {
load_our_contacts(model.contacts, model.pubkey, ev)
load_our_relays(model.pubkey, model.pool, ev)
add_contact_if_friend(model.contacts, ev)
}
/* event_process_deletion updates the list of deleted events.
*/
function model_process_event_deletion(model, ev) {
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "e") {
const evid = tag[1]
// we've already recorded this one as a valid deleted
// event we can just ignore it
if (model.deleted[evid])
continue
let ds = model.deletions[evid] =
(model.deletions[evid] || new Set())
// add the deletion event id to the deletion set of
// this event we will use this to determine if this
// event is valid later in case we don't have the
// deleted event yet.
ds.add(ev.id)
}
}
}
/* model_event_has_unknown_ids checks the event if there are any referenced keys with
* unknown user profiles in the provided scope.
*/
function model_event_has_unknown_ids(damus, ev) {
// make sure this event itself is removed from unknowns
if (ev.kind === 0)
delete damus.unknown_pks[ev.pubkey]
delete damus.unknown_ids[ev.id]
let got_some = false
for (const tag of ev.tags) {
if (tag.length >= 2) {
if (tag[0] === "p") {
const pk = tag[1]
if (!model_has_profile(damus, pk) && is_valid_id(pk)) {
got_some = true
damus.unknown_pks[pk] = make_unk(tag[2], ev)
}
} else if (tag[0] === "e") {
const evid = tag[1]
if (!model_has_event(damus, evid) && is_valid_id(evid)) {
got_some = true
damus.unknown_ids[evid] = make_unk(tag[2], ev)
}
}
}
}
return got_some
}
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
log_debug("received deletion for", ev)
// clean up deletion data that we don't need anymore
delete model.deletions[ev.id]
return true
} else {
log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`)
}
}
return false
}
function model_has_profile(model, pk) {
return pk in model.profiles
}
function model_has_event(model, evid) {
return evid in model.all_events
}
/* model_relay_update_lok returns a map of kinds found in all events based on
* the last seen event of each kind. It also updates the model's cached value.
* If the cached value is found it returns that instead
*/
function model_relay_update_lok(model, relay) {
let last_of_kind = model.last_event_of_kind[relay];
if (!last_of_kind) {
last_of_kind = model.last_event_of_kind[relay]
= calculate_last_of_kind(model.all_events);
}
return last_of_kind;
}
function model_subscribe_defaults(model, relay) {
const lok = model_relay_update_lok(model, relay);
const filters = filters_new_default_since(model, lok);
filters_subscribe(filters, model.pool, [relay]);
}
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;
}
}
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 new_model() {
return {
done_init: {},
notifications: 0,
max_depth: 2,
all_events: {},
reactions_to: {},
chatrooms: {},
unknown_ids: {},
unknown_pks: {},
deletions: {},
but_wait_theres_more: 0,
pow: 0, // pow difficulty target
deleted: {},
profiles: {},
profile_events: {},
last_event_of_kind: {},
contacts: {
event: null,
friends: new Set(),
friend_of_friends: new Set(),
},
invalidated: [],
}
}

View file

@ -1,7 +1,12 @@
function linkify(text, show_media) {
return text.replace(URL_REGEX, function(match, p1, p2, p3) {
const url = p2+p3
const parsed = new URL(url)
const url = p2+p3;
let parsed;
try {
parsed = new URL(url)
} catch (err) {
return match;
}
let html;
if (show_media && is_img_url(parsed.pathname)) {
html = `
@ -26,10 +31,8 @@ function format_content(ev, show_media) {
return "❤️"
return sanitize(ev.content.trim())
}
const content = ev.content.trim()
const body = convert_quote_blocks(content, show_media)
let cw = get_content_warning(ev.tags)
if (cw !== null) {
let cwHTML = "Content Warning"
@ -38,15 +41,13 @@ function format_content(ev, show_media) {
} else {
cwHTML += `: "<span>${cw}</span>".`
}
const open = !!DAMUS.cw_open[ev.id]? "open" : ""
return `
<details ontoggle="toggle_content_warning(this)" class="cw" id="cw_${ev.id}" ${open}>
<details class="cw">
<summary class="event-message">${cwHTML}</summary>
${body}
</details>
`
}
return body
}

View file

@ -2,7 +2,7 @@
// is done by simple string manipulations & templates. If you need to write
// loops simply write it in code and return strings.
function render_timeline_event(damus, view, ev)
/*function render_timeline_event(damus, view, ev)
{
const root_id = get_thread_root_id(damus, ev.id)
const max_depth = root_id ? get_thread_max_depth(damus, view, root_id) : get_default_max_depth(damus, view)
@ -17,7 +17,7 @@ function render_events(damus, view) {
return view.events
.filter((ev, i) => i < 140)
.map((ev) => render_timeline_event(damus, view, ev)).join("\n")
}
}*/
function render_reply_line_top(has_top_line) {
const classes = has_top_line ? "" : "invisible"
@ -97,7 +97,7 @@ function render_unknown_event(damus, ev) {
}
function render_share(damus, view, ev, opts) {
//todo validate content
// TODO validate content
const shared_ev = damus.all_events[ev.refs && ev.refs.root]
// share isn't resolved yet. that's ok, we can render this when we have
// the event
@ -113,7 +113,7 @@ function render_share(damus, view, ev, opts) {
function render_comment_body(damus, ev, opts) {
const can_delete = damus.pubkey === ev.pubkey;
const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, can_delete)
const bar = !can_reply(ev) || opts.nobar ? "" : render_action_bar(damus, ev, {can_delete})
const show_media = !opts.is_composing
return `
@ -157,7 +157,7 @@ function render_deleted_comment_body(ev, deleted) {
`
}
function render_event(damus, view, ev, opts={}) {
/*function render_event(damus, view, ev, opts={}) {
if (ev.kind === 6)
return render_share(damus, view, ev, opts)
if (shouldnt_render_event(damus.pubkey, view, ev, opts))
@ -165,11 +165,9 @@ function render_event(damus, view, ev, opts={}) {
view.rendered.add(ev.id)
const profile = damus.profiles[ev.pubkey]
const delta = time_delta(new Date().getTime(), ev.created_at*1000)
const has_bot_line = opts.is_reply
const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
if (opts.is_composing) has_bot_line = true;
const deleted = is_deleted(damus, ev.id)
if (deleted && !opts.is_reply)
@ -183,29 +181,67 @@ function render_event(damus, view, ev, opts={}) {
}
const has_top_line = replied_events !== ""
const border_bottom = opts.is_composing || has_bot_line ? "" : "bottom-border";
return `
${replied_events}
<div id="ev${ev.id}" class="event ${border_bottom}">
` + render_event2(damus, ev, {
deleted,
has_top_line,
has_bot_line,
reply_line_bot,
});
}*/
function render_event(model, ev, opts={}) {
let {
has_bot_line,
has_top_line,
reply_line_bot,
} = opts
const thread_root = (ev.refs && ev.refs.root) || ev.id;
const profile = model.profiles[ev.pubkey];
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
const border_bottom = opts.is_composing || has_bot_line ? "" : "bottom-border";
let thread_btn = "";
if (!reply_line_bot) reply_line_bot = '';
return `<div id="ev${ev.id}" class="event ${border_bottom}">
<div class="userpic">
${render_reply_line_top(has_top_line)}
${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
${render_pfp(ev.pubkey, profile)}
${reply_line_bot}
</div>
<div class="event-content">
<div class="info">
${render_name(ev.pubkey, profile)}
<span class="timestamp">${delta}</span>
<button class="icon" title="View Thread" role="view-event" data-eid="${ev.id}" onclick="click_event(this)">
<span class="timestamp" data-timestamp="${ev.created_at}">${delta}</span>
<button class="icon" title="View Thread" role="view-event" onclick="open_thread('${thread_root}')">
<img class="icon svg small" src="icon/open-thread.svg"/>
</button>
</div>
<div class="comment">
${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)}
${render_comment_body(model, ev, opts)}
</div>
</div>
</div>
`
</div>`
}
function render_event_nointeract(model, ev, opts={}) {
const profile = model.profiles[ev.pubkey];
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
return `<div class="event border-bottom">
<div class="userpic">
${render_pfp(ev.pubkey, profile)}
</div>
<div class="event-content">
<div class="info">
${render_name(ev.pubkey, profile)}
<span class="timestamp" data-timestamp="${ev.created_at}">${delta}</span>
</div>
<div class="comment">
${render_comment_body(model, ev, opts)}
</div>
</div>
</div>`
}
function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) {
@ -241,7 +277,8 @@ function render_reaction(model, reaction) {
return render_pfp(reaction.pubkey, profile)
}
function render_action_bar(damus, ev, can_delete) {
function render_action_bar(model, ev, opts={}) {
let { can_delete } = opts;
let delete_html = ""
if (can_delete)
delete_html = `
@ -249,10 +286,9 @@ function render_action_bar(damus, ev, can_delete) {
<img class="icon svg small" src="icon/event-delete.svg"/>
</button>`
const groups = get_reactions(damus, ev.id)
const like = "❤️"
const likes = groups[like] || {}
const react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes)
const groups = get_reactions(model, ev.id)
const react_onclick = render_react_onclick(model.pubkey, ev.id,
"❤️", groups["❤️"] || {})
return `
<div class="action-bar">
<button class="icon" title="Reply" onclick="reply_to('${ev.id}')">
@ -317,8 +353,12 @@ function render_mentioned_name(pk, profile) {
function render_name(pk, profile, prefix="") {
return `
<span class="username clickable" onclick="show_profile('${pk}')"
data-pk="${pk}">${prefix}${render_name_plain(profile)}
<span>
${prefix}
<span class="username clickable" data-pubkey="${pk}"
onclick="open_profile('${pk}')">
${render_name_plain(profile)}
</span>
</span>`
}
@ -328,9 +368,13 @@ function render_deleted_name() {
function render_pfp(pk, profile) {
const name = render_name_plain(profile)
return `<img class="pfp" title="${name}"
return `<img
class="pfp clickable"
onclick="open_profile('${pk}')"
data-pubkey="${pk}"
title="${name}"
onerror="this.onerror=null;this.src='${robohash(pk)}';"
src="${get_picture(pk, profile)}">`
src="${get_picture(pk, profile)}"/>`
}
function render_deleted_pfp() {
@ -340,8 +384,7 @@ function render_deleted_pfp() {
</div>`
}
function render_loading_spinner()
{
function render_loading_spinner() {
return `
<div class="loading-events">
<div class="loader" title="Loading...">

View file

@ -13,25 +13,7 @@ function get_thread_max_depth(damus, view, root_id) {
return view.depths[root_id]
}
function shouldnt_render_event(our_pk, view, ev, opts) {
return !opts.is_composing &&
!view.expanded.has(ev.id) &&
view.rendered.has(ev.id)
}
function toggle_content_warning(el) {
const id = el.id.split("_")[1]
const ev = DAMUS.all_events[id]
if (!ev) {
log_debug("could not find content-warning event", id)
return
}
DAMUS.cw_open[id] = el.open
}
function expand_thread(id, reply_id) {
/*function expand_thread(id, reply_id) {
const view = get_current_view()
const root_id = get_thread_root_id(DAMUS, id)
if (!root_id) {
@ -41,7 +23,7 @@ function expand_thread(id, reply_id) {
view.expanded.add(reply_id)
view.depths[root_id] = get_thread_max_depth(DAMUS, view, root_id) + 1
redraw_events(DAMUS, view)
}
}*/
function get_thread_root_id(damus, id) {
const ev = damus.all_events[id]
@ -52,65 +34,190 @@ function get_thread_root_id(damus, id) {
return ev.refs && ev.refs.root
}
function redraw_events(damus, view) {
//log_debug("redrawing events for", view)
view.rendered = new Set()
const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`)
events_el.innerHTML = render_events(damus, view)
function switch_view(mode, opts) {
log_warn("switch_view deprecated, use view_timeline_apply_mode");
view_timeline_apply_mode(DAMUS, mode, opts);
}
function redraw_timeline_events(damus, name) {
const view = DAMUS.views[name]
const events_el = damus.view_el.querySelector(`#${name}-view > .events`)
if (view.events.length > 0) {
redraw_events(damus, view)
} else {
events_el.innerHTML = render_loading_spinner()
function view_get_timeline_el() {
return find_node("#timeline");
}
function view_timeline_update_profiles(model, ev) {
let xs, html;
const el = view_get_timeline_el();
const pk = ev.pubkey;
const p = model.profiles[pk];
// If it's my pubkey let's redraw my pfp that is not located in the view
if (pk == model.pubkey) {
redraw_my_pfp(model);
}
// Update displayed names
xs = el.querySelectorAll(`.username[data-pubkey='${pk}']`)
html = render_name_plain(p);
for (const x of xs) {
x.innerText = html;
}
// Update profile pictures
xs = el.querySelectorAll(`img.pfp[data-pubkey='${pk}']`);
html = get_picture(pk, p)
for (const x of xs) {
x.src = html;
}
}
function switch_view(name, opts={})
{
if (name === DAMUS.current_view) {
log_debug("Not switching to '%s', we are already there", name)
return
function view_timeline_update_timestamps(model) {
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);
}
const last = get_current_view()
if (!last) {
// render initial
DAMUS.current_view = name
redraw_timeline_events(DAMUS, name)
return
}
log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view)
DAMUS.current_view = name
const current = get_current_view()
const last_el = get_view_el(last.name)
const current_el = get_view_el(current.name)
if (last_el)
last_el.classList.add("hide");
// TODO accomodate views that do not render events
// TODO find out if having multiple event divs is slow
//redraw_timeline_events(DAMUS, name)
find_node("#nav > div[data-active]").dataset.active = name;
if (current_el)
current_el.classList.remove("hide");
}
function get_current_view()
{
// TODO resolve memory & html descriptencies
// Currently there is tracking of which divs are visible in HTML/CSS and
// which is active in memory, simply resolve this by finding the visible
// element instead of tracking it in memory (or remove dom elements). This
// would simplify state tracking IMO - Thomas
return DAMUS.views[DAMUS.current_view]
function view_timeline_update_reaction(model, ev) {
// TODO loop through elements with ev reactions to and update them
}
const VM_FRIENDS = "friends";
const VM_EXPLORE = "explore";
const VM_NOTIFICATIONS = "notifications";
const VM_THREAD = "thread";
const VM_USER = "user";
// friends: mine + only events that are from my contacts
// explore: all events
// notifications: reactions & replys
// thread: all events in response to target event
// user: all events by pubkey
function view_mode_contains_event(model, ev, mode, opts={}) {
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:
return ev.id == opts.thread_id || (ev.refs && (
ev.refs.root == opts.thread_id ||
ev.refs.reply == opts.thread_id));
//event_refs_event(ev, opts.thread_id);
case VM_NOTIFICATIONS:
return event_refs_pubkey(ev, model.pubkey);
}
return false;
}
function view_timeline_apply_mode(model, mode, opts={}) {
let xs;
const { pubkey, thread_id } = opts;
const el = view_get_timeline_el();
el.dataset.mode = mode;
switch(mode) {
case VM_THREAD:
el.dataset.threadId = thread_id;
case VM_USER:
el.dataset.pubkey = pubkey;
break;
default:
delete el.dataset.threadId;
delete el.dataset.pubkey;
break;
}
const names = {};
names[VM_FRIENDS] = "Home";
names[VM_EXPLORE] = "Explore";
names[VM_NOTIFICATIONS] = "Notifications";
names[VM_USER] = "Profile";
names[VM_THREAD] = "Thread";
find_node("#view header > label").innerText = mode == VM_USER ? render_name_plain(DAMUS.profiles[opts.pubkey]) : names[mode];
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);
xs = el.querySelectorAll(".event");
for (const x of xs) {
let evid = x.id.substr(2);
let ev = model.all_events[evid];
x.classList.toggle("hide",
!view_mode_contains_event(model, ev, mode, opts));
}
}
/* view_timeline_update iterates through invalidated event ids and either adds
* or removes them from the timeline.
*/
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,
};
// for each event not rendered, go through the list and render it marking
// it as rendered and adding it to the appropriate fragment. fragments are
// created based on slices in the existing timeline. fragments are started
// at the previous event
// const fragments = {};
// const cache = {};
// Dumb function to insert needed events
let visible_count = 0;
const all = model_events_arr(model);
while (model.invalidated.length > 0) {
var evid = model.invalidated.pop();
var ev = model.all_events[evid];
if (!event_is_renderable(ev) || model_is_event_deleted(model, evid)) {
// TODO check deleted
let x = find_node("#ev"+evid, el);
if (x) el.removeChild(x);
continue;
}
// if event is in el already, do nothing or update?
let ev_el = find_node("#ev"+evid, el);
if (ev_el) {
continue;
} else {
let div = document.createElement("div");
div.innerHTML = render_event(model, ev, {});
ev_el = div.firstChild;
if (!view_mode_contains_event(model, ev, mode, opts)) {
ev_el.classList.add("hide");
} else {
visible_count++;
}
}
// find prior event element and insert it before that
let prior_el;
let prior_idx = arr_bsearch_insert(all, ev, event_cmp_created);
while (prior_idx > 0 && !prior_el) {
prior_el = find_node("#ev"+all[prior_idx].id, el);
prior_idx--;
}
if (!prior_el) {
el.appendChild(ev_el);
} else {
el.insertBefore(ev_el, prior_el);
}
}
if (visible_count > 0)
find_node("#view .loading-events").classList.add("hide");
}
function event_is_renderable(ev={}) {
if (ev.is_spam) return false;
if (ev.kind != 1) return false;
return true;
}

View file

@ -172,17 +172,16 @@ async function do_send_reply() {
}
function reply_to(evid) {
const ev = DAMUS.all_events[evid]
const modal = document.querySelector("#reply-modal")
const replybox = modal.querySelector("#reply-content")
modal.classList.remove("closed")
const replying_to = modal.querySelector("#replying-to")
replying_to.dataset.evid = evid
const ev = DAMUS.all_events[evid]
const view = get_current_view()
replying_to.innerHTML = render_event(DAMUS, view, ev, {is_composing: true, nobar: true, max_depth: 1})
replying_to.innerHTML = render_event_nointeract(DAMUS, ev, {
is_composing: true,
nobar: true
});
modal.classList.remove("closed")
replybox.focus()
}
@ -190,7 +189,7 @@ function redraw_my_pfp(model, force = false) {
const p = model.profiles[model.pubkey]
if (!p) return;
const html = render_pfp(model.pubkey, p);
const el = document.querySelector(".my-userpic")
const el = document.querySelector(".my-userpic");
if (!force && el.dataset.loaded) return;
el.dataset.loaded = true;
el.innerHTML = html;
@ -261,4 +260,35 @@ function get_privkey() {
return privkey
}
function open_thread(thread_id) {
view_timeline_apply_mode(DAMUS, VM_THREAD, { thread_id });
}
function open_profile(pubkey) {
view_timeline_apply_mode(DAMUS, VM_USER, { pubkey });
const profile = DAMUS.profiles[pubkey];
const el = find_node("[role='profile-info']");
// TODO show loading indicator then render
find_node("[role='profile-image']", el).src = get_picture(pubkey, profile);
find_nodes("[role='profile-name']", el).forEach(el => {
el.innerText = render_name_plain(profile);
});
const el_nip5 = find_node("[role='profile-nip5']", el)
el_nip5.innerText = profile.nip05;
el_nip5.classList.toggle("hide", !profile.nip05);
const el_desc = find_node("[role='profile-desc']", el)
el_desc.innerHTML = newlines_to_br(profile.about);
el_desc.classList.toggle("hide", !profile.about);
find_node("button[role='copy-pk']", el).dataset.pk = pubkey;
const btn_follow = find_node("button[role='follow-user']", el)
btn_follow.dataset.pk = pubkey;
// TODO check follow status
btn_follow.innerText = contact_is_friend(DAMUS.contacts, pubkey) ? "Unfollow" : "Follow";
btn_follow.classList.toggle("hide", pubkey == DAMUS.pubkey);
}

107
web/js/unknowns.js Normal file
View file

@ -0,0 +1,107 @@
function make_unk(hint, ev) {
const attempts = 0
const parent_created = ev.created_at
if (hint && hint !== "")
return {attempts, hint: hint.trim().toLowerCase(), parent_created}
return {attempts, parent_created}
}
function gather_unknown_hints(damus, pks, evids)
{
let relays = new Set()
for (const pk of pks) {
const unk = damus.unknown_pks[pk]
if (unk && unk.hint && unk.hint !== "")
relays.add(unk.hint)
}
for (const evid of evids) {
const unk = damus.unknown_ids[evid]
if (unk && unk.hint && unk.hint !== "")
relays.add(unk.hint)
}
return Array.from(relays)
}
function get_non_expired_unknowns(unks, type)
{
const MAX_ATTEMPTS = 2
function sort_parent_created(a_id, b_id) {
const a = unks[a_id]
const b = unks[b_id]
return b.parent_created - a.parent_created
}
let new_expired = 0
const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => {
if (ids.length >= 255)
return ids
const unk = unks[unk_id]
if (unk.attempts >= MAX_ATTEMPTS) {
if (!unk.expired) {
unk.expired = true
new_expired++
}
return ids
}
unk.attempts++
ids.push(unk_id)
return ids
}, [])
if (new_expired !== 0)
log_debug("Gave up looking for %d %s", new_expired, type)
return ids
}
function fetch_unknown_events(damus)
{
let filters = []
const pks = get_non_expired_unknowns(damus.unknown_pks, 'profiles')
const evids = get_non_expired_unknowns(damus.unknown_ids, 'events')
const relays = gather_unknown_hints(damus, pks, evids)
for (const relay of relays) {
if (!damus.pool.has(relay)) {
log_debug("adding %s to relays to fetch unknown events", relay)
damus.pool.add(relay)
}
}
if (evids.length !== 0) {
const unk_kinds = [1,5,6,7,40,42] // TODO don't hardcode event kinds
filters.push({ids: evids, kinds: unk_kinds})
filters.push({"#e": evids, kinds: [1,42], limit: 100})
}
if (pks.length !== 0)
filters.push({authors: pks, kinds:[0]})
if (filters.length === 0)
return
log_debug("fetching unknowns", filters)
damus.pool.subscribe('unknowns', filters)
}
function schedule_unknown_refetch(damus)
{
const INTERVAL = 5000
if (!damus.unknown_timer) {
log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000)
damus.unknown_timer = setTimeout(() => {
fetch_unknown_events(damus)
setTimeout(() => {
delete damus.unknown_timer
if (damus.but_wait_theres_more > 0) {
damus.but_wait_theres_more = 0
schedule_unknown_refetch(damus)
}
}, INTERVAL)
}, INTERVAL)
fetch_unknown_events(damus)
} else {
damus.but_wait_theres_more++
}
}

View file

@ -8,20 +8,21 @@ const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\
function log_error(fmt, ...args) {
console.error(fmt, ...args)
}
function log_debug(fmt, ...args) {
console.debug(fmt, ...args)
}
function log_info(fmt, ...args) {
console.info(fmt, ...args)
}
function log_warn(fmt, ...args) {
console.warn(fmt, ...args)
}
function safe_parse_json(data, message) {
let value = undefined;
try {
value = JSON.parse(data);
} catch (e) {
} catch (err) {
log_error(`${message} : unable to parse JSON`, err, data);
}
return value;
@ -49,6 +50,25 @@ function shuffle(arr) {
return arr;
}
/* arr_bsearch_insert finds the point in the array that an item should be
* inserted at based on the 'cmp' function used.
*/
function arr_bsearch_insert(arr, item, cmp) {
let start = 0;
let end = arr.length - 1;
while (start <= end) {
let middle = parseInt((start + end) / 2);
let x = cmp(item, arr[middle])
if (x > 0)
start = middle + 1;
else if (x < 0)
end = middle - 1;
else
return middle;
}
return start;
}
function is_valid_time(now_sec, created_at) {
// don't count events far in the future
if (created_at - now_sec >= 120) {
@ -128,39 +148,14 @@ function difficulty_to_prefix(d) {
return s
}
function calculate_pow(ev) {
const id_bits = leading_zero_bits(ev.id)
for (const tag of ev.tags) {
if (tag.length >= 3 && tag[0] === "nonce") {
const target = +tag[2]
if (isNaN(target))
return 0
// if our nonce target is smaller than the difficulty,
// then we use the nonce target as the actual difficulty
return min(target, id_bits)
}
}
// not a valid pow if we don't have a difficulty target
return 0
}
/* can_reply returns a boolean value based on if you can "reply" to the event
* in the manner of a chat.
*/
function can_reply(ev) {
return ev.kind === 1 || ev.kind === 42
}
function should_add_to_timeline(ev) {
// TODO rename should_add_to_timeline to is_timeline_event
return ev.kind === 1 || ev.kind === 42 || ev.kind === 6
}
/* time_delta returns a string of the time of current since previous.
*/
function time_delta(current, previous) {
log_warn("time_delta deprecated, use fmt_since_str");
fmt_since_str(current, previous);
}
function fmt_since_str(current, previous) {
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
@ -239,12 +234,6 @@ function get_picture(pk, profile) {
return profile.resolved_picture
}
function passes_spam_filter(contacts, ev, pow) {
if (contacts.friend_of_friends.has(ev.pubkey))
return true
return ev.pow >= pow
}
function debounce(f, interval) {
let timer = null;
let first = true;
@ -257,3 +246,8 @@ function debounce(f, interval) {
};
}
function process_json_content(ev) {
ev.json_content = safe_parse_json(ev.content, "event json_content");
}