damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

damus.js (36187B)


      1 
      2 let DAMUS
      3 
      4 const BOOTSTRAP_RELAYS = [
      5 	"wss://relay.damus.io",
      6 	"wss://nostr-relay.wlvs.space",
      7 	"wss://nostr-pub.wellorder.net"
      8 ]
      9 
     10 const DEFAULT_PROFILE = {
     11 	name: "anon",
     12 	display_name: "Anonymous",
     13 }
     14 
     15 function insert_event_sorted(evs, new_ev) {
     16         for (let i = 0; i < evs.length; i++) {
     17                 const ev = evs[i]
     18 
     19                 if (new_ev.id === ev.id) {
     20                         return false
     21                 }
     22 
     23                 if (new_ev.created_at > ev.created_at) {
     24                         evs.splice(i, 0, new_ev)
     25                         return true
     26                 }
     27         }
     28 
     29         evs.push(new_ev)
     30         return true
     31 }
     32 
     33 function init_contacts() {
     34 	return {
     35 		event: null,
     36 		friends: new Set(),
     37 		friend_of_friends: new Set(),
     38 	}
     39 }
     40 
     41 function init_timeline(name) {
     42 	return {
     43 		name,
     44 		events: [],
     45 		rendered: new Set(),
     46 		depths: {},
     47 		expanded: new Set(),
     48 	}
     49 }
     50 
     51 function init_home_model() {
     52 	return {
     53 		done_init: {},
     54 		notifications: 0,
     55 		max_depth: 2,
     56 		all_events: {},
     57 		reactions_to: {},
     58 		chatrooms: {},
     59 		unknown_ids: {},
     60 		unknown_pks: {},
     61 		deletions: {},
     62 		but_wait_theres_more: 0,
     63 		cw_open: {},
     64 		views: {
     65 			home: init_timeline('home'),
     66 			explore: {
     67 				...init_timeline('explore'),
     68 				seen: new Set(),
     69 			},
     70 			notifications: {
     71 				...init_timeline('notifications'),
     72 				max_depth: 1,
     73 			},
     74 			profile: init_timeline('profile'),
     75 			thread: init_timeline('thread'),
     76 		},
     77 		pow: 0, // pow difficulty target
     78 		deleted: {},
     79 		profiles: {},
     80 		profile_events: {},
     81 		last_event_of_kind: {},
     82 		contacts: init_contacts()
     83 	}
     84 }
     85 
     86 function update_favicon(path)
     87 {
     88 	let link = document.querySelector("link[rel~='icon']");
     89 	const head = document.getElementsByTagName('head')[0]
     90 
     91 	if (!link) {
     92 		link = document.createElement('link');
     93 		link.rel = 'icon';
     94 		head.appendChild(link);
     95 	}
     96 
     97 	link.href = path;
     98 }
     99 
    100 // update_title updates the document title & visual indicators based on if the
    101 // number of notifications that are unseen by the user.
    102 function update_title(model) {
    103 	// TODO rename update_title to update_notification_state or similar
    104 	// TODO only clear notifications once they have seen all targeted events
    105 	if (document.visibilityState === 'visible') {
    106 		model.notifications = 0
    107 	}
    108 
    109 	const num = model.notifications
    110 	const has_notes = num !== 0
    111 	document.title = has_notes ? `(${num}) Damus` : "Damus";
    112 	update_favicon(has_notes ? "img/damus_notif.svg" : "img/damus.svg");
    113 	update_notification_markers(has_notes)
    114 }
    115 
    116 function notice_chatroom(state, id)
    117 {
    118 	if (!state.chatrooms[id])
    119 		state.chatrooms[id] = {}
    120 }
    121 
    122 async function damus_web_init()
    123 {
    124 	init_message_textareas();
    125 
    126 	let tries = 0;
    127 	function init() {
    128 		// only wait for 500ms max
    129 		const max_wait = 500
    130 		const interval = 20
    131 		if (window.nostr || tries >= (max_wait/interval)) {
    132 			console.info("init after", tries);
    133 			damus_web_init_ready();
    134 			return;
    135 		}
    136 		tries++;
    137 		setTimeout(init, interval);
    138 	}
    139 	init();
    140 }
    141 
    142 async function damus_web_init_ready()
    143 {
    144 	const model = init_home_model()
    145 	DAMUS = model
    146 	model.pubkey = await get_pubkey()
    147 	if (!model.pubkey)
    148 		return
    149 	const {RelayPool} = nostrjs
    150 	const pool = RelayPool(BOOTSTRAP_RELAYS)
    151 	const now = (new Date().getTime()) / 1000
    152 
    153 	const ids = {
    154 		comments: "comments",//uuidv4(),
    155 		profiles: "profiles",//uuidv4(),
    156 		explore: "explore",//uuidv4(),
    157 		refevents: "refevents",//uuidv4(),
    158 		account: "account",//uuidv4(),
    159 		home: "home",//uuidv4(),
    160 		contacts: "contacts",//uuidv4(),
    161 		notifications: "notifications",//uuidv4(),
    162 		unknowns: "unknowns",//uuidv4(),
    163 		dms: "dms",//uuidv4(),
    164 	}
    165 
    166 	model.ids = ids
    167 
    168 	model.pool = pool
    169 
    170 	load_cache(model)
    171 	model.view_el = document.querySelector("#view")
    172 
    173 	switch_view('home')
    174 
    175 	document.addEventListener('visibilitychange', () => {
    176 		update_title(model)
    177 	})
    178 
    179 	pool.on('open', (relay) => {
    180 		//let authors = followers
    181 		// TODO: fetch contact list
    182 		log_debug("relay connected", relay.url)
    183 
    184 		if (!model.done_init[relay]) {
    185 			send_initial_filters(ids.account, model.pubkey, relay)
    186 		} else {
    187 			send_home_filters(model, relay)
    188 		}
    189 		//relay.subscribe(comments_id, {kinds: [1,42], limit: 100})
    190 	});
    191 
    192 	pool.on('event', (relay, sub_id, ev) => {
    193 		handle_home_event(model, relay, sub_id, ev)
    194 	})
    195 
    196 	pool.on('notice', (relay, notice) => {
    197 		log_debug("NOTICE", relay, notice)
    198 	})
    199 
    200 	pool.on('eose', async (relay, sub_id) => {
    201 		if (sub_id === ids.home) {
    202 			//log_debug("got home EOSE from %s", relay.url)
    203 			const events = model.views.home.events
    204 			handle_comments_loaded(ids, model, events, relay)
    205 		} else if (sub_id === ids.profiles) {
    206 			//log_debug("got profiles EOSE from %s", relay.url)
    207 			const view = get_current_view()
    208 			handle_profiles_loaded(ids, model, view, relay)
    209 		} else if (sub_id === ids.unknowns) {
    210 			model.pool.unsubscribe(ids.unknowns, relay)
    211 		}
    212 	})
    213 
    214 	return pool
    215 }
    216 
    217 function process_reaction_event(model, ev)
    218 {
    219 	if (!is_valid_reaction_content(ev.content))
    220 		return
    221 
    222 	let last = {}
    223 
    224 	for (const tag of ev.tags) {
    225 		if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"))
    226 			last[tag[0]] = tag[1]
    227 	}
    228 
    229 	if (last.e) {
    230 		model.reactions_to[last.e] = model.reactions_to[last.e] || new Set()
    231 		model.reactions_to[last.e].add(ev.id)
    232 	}
    233 }
    234 
    235 function process_chatroom_event(model, ev)
    236 {
    237 	try {
    238 		model.chatrooms[ev.id] = sanitize_obj(JSON.parse(ev.content))
    239 	} catch (err) {
    240 		log_debug("error processing chatroom creation event", ev, err)
    241 	}
    242 }
    243 
    244 function process_deletion_event(model, ev)
    245 {
    246 	for (const tag of ev.tags) {
    247 		if (tag.length >= 2 && tag[0] === "e") {
    248 			const evid = tag[1]
    249 
    250 			// we've already recorded this one as a valid deleted
    251 			// event we can just ignore it
    252 			if (model.deleted[evid])
    253 				continue
    254 
    255 			let ds = model.deletions[evid] =
    256 				(model.deletions[evid] || new Set())
    257 
    258 			// add the deletion event id to the deletion set of
    259 			// this event we will use this to determine if this
    260 			// event is valid later in case we don't have the
    261 			// deleted event yet.
    262 			ds.add(ev.id)
    263 		}
    264 	}
    265 }
    266 
    267 function is_deleted(model, evid)
    268 {
    269 	// we've already know it's deleted
    270 	if (model.deleted[evid])
    271 		return model.deleted[evid]
    272 
    273 	const ev = model.all_events[evid]
    274 	if (!ev)
    275 		return false
    276 
    277 	// all deletion events
    278 	const ds = model.deletions[ev.id]
    279 	if (!ds)
    280 		return false
    281 
    282 	// find valid deletion events
    283 	for (const id of ds.keys()) {
    284 		const d_ev = model.all_events[id]
    285 		if (!d_ev)
    286 			continue
    287 
    288 		// only allow deletes from the user who created it
    289 		if (d_ev.pubkey === ev.pubkey) {
    290 			model.deleted[ev.id] = d_ev
    291 			log_debug("received deletion for", ev)
    292 			// clean up deletion data that we don't need anymore
    293 			delete model.deletions[ev.id]
    294 			return true
    295 		} else {
    296 			log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`)
    297 		}
    298 	}
    299 
    300 	return false
    301 }
    302 
    303 function has_profile(damus, pk) {
    304 	return pk in damus.profiles
    305 }
    306 
    307 function has_event(damus, evid) {
    308 	return evid in damus.all_events
    309 }
    310 
    311 const ID_REG = /^[a-f0-9]{64}$/
    312 function is_valid_id(evid)
    313 {
    314 	return ID_REG.test(evid)
    315 }
    316 
    317 function make_unk(hint, ev)
    318 {
    319 	const attempts = 0
    320 	const parent_created = ev.created_at
    321 
    322 	if (hint && hint !== "")
    323 		return {attempts, hint: hint.trim().toLowerCase(), parent_created}
    324 
    325 	return {attempts, parent_created}
    326 }
    327 
    328 function notice_unknown_ids(damus, ev)
    329 {
    330 	// make sure this event itself is removed from unknowns
    331 	if (ev.kind === 0)
    332 		delete damus.unknown_pks[ev.pubkey]
    333 	delete damus.unknown_ids[ev.id]
    334 
    335 	let got_some = false
    336 
    337 	for (const tag of ev.tags) {
    338 		if (tag.length >= 2) {
    339 			if (tag[0] === "p") {
    340 				const pk = tag[1]
    341 				if (!has_profile(damus, pk) && is_valid_id(pk)) {
    342 					got_some = true
    343 					damus.unknown_pks[pk] = make_unk(tag[2], ev)
    344 				}
    345 			} else if (tag[0] === "e") {
    346 				const evid = tag[1]
    347 				if (!has_event(damus, evid) && is_valid_id(evid)) {
    348 					got_some = true
    349 					damus.unknown_ids[evid] = make_unk(tag[2], ev)
    350 				}
    351 			}
    352 		}
    353 	}
    354 
    355 	return got_some
    356 }
    357 
    358 function gather_unknown_hints(damus, pks, evids)
    359 {
    360 	let relays = new Set()
    361 
    362 	for (const pk of pks) {
    363 		const unk = damus.unknown_pks[pk]
    364 		if (unk && unk.hint && unk.hint !== "")
    365 			relays.add(unk.hint)
    366 	}
    367 
    368 	for (const evid of evids) {
    369 		const unk = damus.unknown_ids[evid]
    370 		if (unk && unk.hint && unk.hint !== "")
    371 			relays.add(unk.hint)
    372 	}
    373 
    374 	return Array.from(relays)
    375 }
    376 
    377 function get_non_expired_unknowns(unks, type)
    378 {
    379 	const MAX_ATTEMPTS = 2
    380 
    381 	function sort_parent_created(a_id, b_id) {
    382 		const a = unks[a_id]
    383 		const b = unks[b_id]
    384 		return b.parent_created - a.parent_created
    385 	}
    386 
    387 	let new_expired = 0
    388 	const ids = Object.keys(unks).sort(sort_parent_created).reduce((ids, unk_id) => {
    389 		if (ids.length >= 255)
    390 			return ids
    391 
    392 		const unk = unks[unk_id]
    393 		if (unk.attempts >= MAX_ATTEMPTS) {
    394 			if (!unk.expired) {
    395 				unk.expired = true
    396 				new_expired++
    397 			}
    398 			return ids
    399 		}
    400 
    401 		unk.attempts++
    402 
    403 		ids.push(unk_id)
    404 		return ids
    405 	}, [])
    406 
    407 	if (new_expired !== 0)
    408 		log_debug("Gave up looking for %d %s", new_expired, type)
    409 
    410 	return ids
    411 }
    412 
    413 function fetch_unknown_events(damus)
    414 {
    415 	let filters = []
    416 
    417 	const pks = get_non_expired_unknowns(damus.unknown_pks, 'profiles')
    418 	const evids = get_non_expired_unknowns(damus.unknown_ids, 'events')
    419 
    420 	const relays = gather_unknown_hints(damus, pks, evids)
    421 
    422 	for (const relay of relays) {
    423 		if (!damus.pool.has(relay)) {
    424 			log_debug("adding %s to relays to fetch unknown events", relay)
    425 			damus.pool.add(relay)
    426 		}
    427 	}
    428 
    429 	if (evids.length !== 0) {
    430 		const unk_kinds = [1,5,6,7,40,42]
    431 		filters.push({ids: evids, kinds: unk_kinds})
    432 		filters.push({"#e": evids, kinds: [1,42], limit: 100})
    433 	}
    434 
    435 	if (pks.length !== 0)
    436 		filters.push({authors: pks, kinds:[0]})
    437 
    438 	if (filters.length === 0)
    439 		return
    440 
    441 	log_debug("fetching unknowns", filters)
    442 	damus.pool.subscribe('unknowns', filters)
    443 }
    444 
    445 function shuffle(arr)
    446 {
    447 	let i = arr.length;
    448 	while (--i > 0) {
    449 		let randIndex = Math.floor(Math.random() * (i + 1));
    450 		[arr[randIndex], arr[i]] = [arr[i], arr[randIndex]];
    451 	}
    452 	return arr;
    453 }
    454 
    455 
    456 function schedule_unknown_refetch(damus)
    457 {
    458 	const INTERVAL = 5000
    459 	if (!damus.unknown_timer) {
    460 		log_debug("fetching unknown events now and in %d seconds", INTERVAL / 1000)
    461 
    462 		damus.unknown_timer = setTimeout(() => {
    463 			fetch_unknown_events(damus)
    464 
    465 			setTimeout(() => {
    466 				delete damus.unknown_timer
    467 				if (damus.but_wait_theres_more > 0) {
    468 					damus.but_wait_theres_more = 0
    469 					schedule_unknown_refetch(damus)
    470 				}
    471 			}, INTERVAL)
    472 		}, INTERVAL)
    473 
    474 		fetch_unknown_events(damus)
    475 	} else {
    476 		damus.but_wait_theres_more++
    477 	}
    478 }
    479 
    480 function process_event(damus, ev)
    481 {
    482 	ev.refs = determine_event_refs(ev.tags)
    483 	const notified = was_pubkey_notified(damus.pubkey, ev)
    484 	ev.notified = notified
    485 
    486 	const got_some_unknowns = notice_unknown_ids(damus, ev)
    487 	if (got_some_unknowns)
    488 		schedule_unknown_refetch(damus)
    489 
    490 	ev.pow = calculate_pow(ev)
    491 
    492 	if (ev.kind === 7)
    493 		process_reaction_event(damus, ev)
    494 	else if (ev.kind === 42 && ev.refs && ev.refs.root)
    495 		notice_chatroom(damus, ev.refs.root)
    496 	else if (ev.kind === 40)
    497 		process_chatroom_event(damus, ev)
    498 	else if (ev.kind === 5)
    499 		process_deletion_event(damus, ev)
    500 	else if (ev.kind === 0)
    501 		process_profile_event(damus, ev)
    502 	else if (ev.kind === 3)
    503 		process_contact_event(damus, ev)
    504 
    505 	const last_notified = get_local_state('last_notified_date')
    506 	if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
    507 		set_local_state('last_notified_date', new Date().getTime())
    508 		damus.notifications++
    509 		update_title(damus)
    510 	}
    511 }
    512 
    513 function was_pubkey_notified(pubkey, ev)
    514 {
    515 	if (!(ev.kind === 1 || ev.kind === 42))
    516 		return false
    517 
    518 	if (ev.pubkey === pubkey)
    519 		return false
    520 
    521 	for (const tag of ev.tags) {
    522 		if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
    523 			return true
    524 	}
    525 
    526 	return false
    527 }
    528 
    529 function should_add_to_notification_timeline(our_pk, contacts, ev, pow)
    530 {
    531 	if (!should_add_to_timeline(ev))
    532 		return false
    533 
    534 	// TODO: add items that don't pass spam filter to "message requests"
    535 	// Then we will need a way to whitelist people as an alternative to
    536 	// following them
    537 	return passes_spam_filter(contacts, ev, pow)
    538 }
    539 
    540 function should_add_to_explore_timeline(contacts, view, ev, pow)
    541 {
    542 	if (!should_add_to_timeline(ev))
    543 		return false
    544 
    545 	if (view.seen.has(ev.pubkey))
    546 		return false
    547 
    548 	// hide friends for 0-pow situations
    549 	if (pow === 0 && contacts.friends.has(ev.pubkey))
    550 		return false
    551 
    552 	return passes_spam_filter(contacts, ev, pow)
    553 }
    554 
    555 function get_current_view()
    556 {
    557 	// TODO resolve memory & html descriptencies
    558 	// Currently there is tracking of which divs are visible in HTML/CSS and
    559 	// which is active in memory, simply resolve this by finding the visible
    560 	// element instead of tracking it in memory (or remove dom elements). This
    561 	// would simplify state tracking IMO - Thomas
    562 	return DAMUS.views[DAMUS.current_view]
    563 }
    564 
    565 function handle_redraw_logic(model, view_name)
    566 {
    567 	const view = model.views[view_name]
    568 	if (view.redraw_timer)
    569 		clearTimeout(view.redraw_timer)
    570 	view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 600)
    571 }
    572 
    573 function schedule_save_events(damus)
    574 {
    575 	if (damus.save_timer)
    576 		clearTimeout(damus.save_timer)
    577 	damus.save_timer = setTimeout(save_cache.bind(null, damus), 3000)
    578 }
    579 
    580 function is_valid_time(now_sec, created_at)
    581 {
    582 	// don't count events far in the future
    583 	if (created_at - now_sec >= 120) {
    584 		return false
    585 	}
    586 	return true
    587 }
    588 
    589 function max(a, b) {
    590 	return a > b ? a : b
    591 }
    592 
    593 function calculate_last_of_kind(evs)
    594 {
    595 	const now_sec = new Date().getTime() / 1000
    596 	return Object.keys(evs).reduce((obj, evid) => {
    597 		const ev = evs[evid]
    598 		if (!is_valid_time(now_sec, ev.created_at))
    599 			return obj
    600 		const prev = obj[ev.kind] || 0
    601 		obj[ev.kind] = get_since_time(max(ev.created_at, prev))
    602 		return obj
    603 	}, {})
    604 }
    605 
    606 function load_events(damus)
    607 {
    608 	if (!('event_cache' in localStorage))
    609 		return {}
    610 	const cached = JSON.parse(localStorage.getItem('event_cache'))
    611 
    612 	return cached.reduce((obj, ev) => {
    613 		obj[ev.id] = ev
    614 		process_event(damus, ev)
    615 		return obj
    616 	}, {})
    617 }
    618 
    619 function load_cache(damus)
    620 {
    621 	damus.all_events = load_events(damus)
    622 	load_timelines(damus)
    623 }
    624 
    625 function save_cache(damus)
    626 {
    627 	save_events(damus)
    628 	save_timelines(damus)
    629 }
    630 
    631 function save_events(damus)
    632 {
    633 	const keys = Object.keys(damus.all_events)
    634 	const MAX_KINDS = {
    635 		1: 2000,
    636 		0: 2000,
    637 
    638 		6: 100,
    639 		4: 100,
    640 		5: 100,
    641 		7: 100,
    642 	}
    643 
    644 	let counts = {}
    645 
    646 	let cached = keys.map((key) => {
    647 		const ev = damus.all_events[key]
    648 		const {sig, pubkey, content, tags, kind, created_at, id} = ev
    649 		return {sig, pubkey, content, tags, kind, created_at, id}
    650 	})
    651 
    652 	cached.sort((a,b) => b.created_at - a.created_at)
    653 	cached = cached.reduce((cs, ev) => {
    654 		counts[ev.kind] = (counts[ev.kind] || 0)+1
    655 		if (counts[ev.kind] < MAX_KINDS[ev.kind])
    656 			cs.push(ev)
    657 		return cs
    658 	}, [])
    659 
    660 	log_debug('saving all events to local storage', cached.length)
    661 
    662 	localStorage.setItem('event_cache', JSON.stringify(cached))
    663 }
    664 
    665 function save_timelines(damus)
    666 {
    667 	const views = Object.keys(damus.views).reduce((obj, view_name) => {
    668 		const view = damus.views[view_name]
    669 		obj[view_name] = view.events.map(e => e.id).slice(0,100)
    670 		return obj
    671 	}, {})
    672 	localStorage.setItem('views', JSON.stringify(views))
    673 }
    674 
    675 function load_timelines(damus)
    676 {
    677 	if (!('views' in localStorage))
    678 		return
    679 	const stored_views = JSON.parse(localStorage.getItem('views'))
    680 	for (const view_name of Object.keys(damus.views)) {
    681 		const view = damus.views[view_name]
    682 		view.events = (stored_views[view_name] || []).reduce((evs, evid) => {
    683 			const ev = damus.all_events[evid]
    684 			if (ev) evs.push(ev)
    685 			return evs
    686 		}, [])
    687 	}
    688 }
    689 
    690 function handle_home_event(model, relay, sub_id, ev) {
    691 	const ids = model.ids
    692 
    693 	// ignore duplicates
    694 	if (!has_event(model, ev.id)) {
    695 		model.all_events[ev.id] = ev
    696 		process_event(model, ev)
    697 		schedule_save_events(model)
    698 	}
    699 
    700 	ev = model.all_events[ev.id]
    701 
    702 	let is_new = true
    703 	switch (sub_id) {
    704 	case model.ids.explore:
    705 		const view = model.views.explore
    706 
    707 		// show more things in explore timeline
    708 		if (should_add_to_explore_timeline(model.contacts, view, ev, model.pow)) {
    709 			view.seen.add(ev.pubkey)
    710 			is_new = insert_event_sorted(view.events, ev)
    711 		}
    712 
    713 		if (is_new)
    714 			handle_redraw_logic(model, 'explore')
    715 		break;
    716 
    717 	case model.ids.notifications:
    718 		if (should_add_to_notification_timeline(model.pubkey, model.contacts, ev, model.pow))
    719 			is_new = insert_event_sorted(model.views.notifications.events, ev)
    720 
    721 		if (is_new)
    722 			handle_redraw_logic(model, 'notifications')
    723 		break;
    724 
    725 	case model.ids.home:
    726 		if (should_add_to_timeline(ev))
    727 			is_new = insert_event_sorted(model.views.home.events, ev)
    728 
    729 		if (is_new)
    730 			handle_redraw_logic(model, 'home')
    731 		break;
    732 	case model.ids.account:
    733 		switch (ev.kind) {
    734 		case 3:
    735 			model.done_init[relay] = true
    736 			model.pool.unsubscribe(model.ids.account, relay)
    737 			send_home_filters(model, relay)
    738 			break
    739 		}
    740 		break
    741 	case model.ids.profiles:
    742 		break
    743 	}
    744 }
    745 
    746 function sanitize_obj(obj) {
    747 	for (const key of Object.keys(obj)) {
    748 		obj[key] = sanitize(obj[key])
    749 	}
    750 
    751 	return obj
    752 }
    753 
    754 function process_profile_event(model, ev) {
    755 	const prev_ev = model.all_events[model.profile_events[ev.pubkey]]
    756 	if (prev_ev && prev_ev.created_at > ev.created_at)
    757 		return
    758 
    759 	model.profile_events[ev.pubkey] = ev.id
    760 	try {
    761 		model.profiles[ev.pubkey] = sanitize_obj(JSON.parse(ev.content))
    762 	} catch(e) {
    763 		log_debug("failed to parse profile contents", ev)
    764 	}
    765 }
    766 
    767 function send_initial_filters(account_id, pubkey, relay) {
    768 	const filter = {authors: [pubkey], kinds: [3], limit: 1}
    769 	//console.log("sending initial filter", filter)
    770 	relay.subscribe(account_id, filter)
    771 }
    772 
    773 function send_home_filters(model, relay) {
    774 	const ids = model.ids
    775 	const friends = contacts_friend_list(model.contacts)
    776 	friends.push(model.pubkey)
    777 
    778 	const contacts_filter = {kinds: [0], authors: friends}
    779 	const dms_filter = {kinds: [4], "#p": [ model.pubkey ], limit: 100}
    780 	const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 100}
    781 
    782 	const standard_kinds = [1,42,5,6,7]
    783 
    784 	const home_filter = {kinds: standard_kinds, authors: friends, limit: 500}
    785 
    786 	// TODO: include pow+fof spam filtering in notifications query
    787 	const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100}
    788 
    789 	let home_filters = [home_filter]
    790 	let notifications_filters = [notifications_filter]
    791 	let contacts_filters = [contacts_filter]
    792 	let dms_filters = [dms_filter, our_dms_filter]
    793 
    794 	let last_of_kind = {}
    795 	if (relay) {
    796 		last_of_kind =
    797 			model.last_event_of_kind[relay] =
    798 			model.last_event_of_kind[relay] || calculate_last_of_kind(model.all_events)
    799 
    800 		log_debug("last_of_kind", last_of_kind)
    801 	}
    802 
    803         update_filters_with_since(last_of_kind, home_filters)
    804         update_filters_with_since(last_of_kind, contacts_filters)
    805         update_filters_with_since(last_of_kind, notifications_filters)
    806         update_filters_with_since(last_of_kind, dms_filters)
    807 
    808 	const subto = relay? [relay] : undefined
    809 	model.pool.subscribe(ids.home, home_filters, subto)
    810 	model.pool.subscribe(ids.contacts, contacts_filters, subto)
    811 	model.pool.subscribe(ids.notifications, notifications_filters, subto)
    812 	model.pool.subscribe(ids.dms, dms_filters, subto)
    813 }
    814 
    815 function update_filter_with_since(last_of_kind, filter) {
    816 	const kinds = filter.kinds || []
    817 	let initial = null
    818 	let earliest = kinds.reduce((earliest, kind) => {
    819 		const last_created_at = last_of_kind[kind]
    820 		let since = get_since_time(last_created_at)
    821 
    822 		if (!earliest) {
    823 			if (since === null)
    824 				return null
    825 
    826 			return since
    827 		}
    828 
    829 		if (since === null)
    830 			return earliest
    831 
    832 		return since < earliest ? since : earliest
    833 
    834 	}, initial)
    835 
    836 	if (earliest)
    837 		filter.since = earliest
    838 }
    839 
    840 function update_filters_with_since(last_of_kind, filters) {
    841 	for (const filter of filters) {
    842 		update_filter_with_since(last_of_kind, filter)
    843 	}
    844 }
    845 
    846 function contacts_friend_list(contacts) {
    847 	return Array.from(contacts.friends)
    848 }
    849 
    850 function contacts_friendosphere(contacts) {
    851 	let s = new Set()
    852 	let fs = []
    853 
    854 	for (const friend of contacts.friends.keys()) {
    855 		fs.push(friend)
    856 		s.add(friend)
    857 	}
    858 
    859 	for (const friend of contacts.friend_of_friends.keys()) {
    860 		if (!s.has(friend))
    861 			fs.push(friend)
    862 	}
    863 
    864 	return fs
    865 }
    866 
    867 function process_contact_event(model, ev) {
    868 	load_our_contacts(model.contacts, model.pubkey, ev)
    869 	load_our_relays(model.pubkey, model.pool, ev)
    870 	add_contact_if_friend(model.contacts, ev)
    871 }
    872 
    873 function add_contact_if_friend(contacts, ev) {
    874 	if (!contact_is_friend(contacts, ev.pubkey))
    875 		return
    876 
    877 	add_friend_contact(contacts, ev)
    878 }
    879 
    880 function contact_is_friend(contacts, pk) {
    881 	return contacts.friends.has(pk)
    882 }
    883 
    884 function add_friend_contact(contacts, contact) {
    885 	contacts.friends.add(contact.pubkey)
    886 
    887 	for (const tag of contact.tags) {
    888 		if (tag.length >= 2 && tag[0] == "p") {
    889 			if (!contact_is_friend(contacts, tag[1]))
    890 				contacts.friend_of_friends.add(tag[1])
    891 		}
    892 	}
    893 }
    894 
    895 function get_view_el(name)
    896 {
    897 	return DAMUS.view_el.querySelector(`#${name}-view`)
    898 }
    899 
    900 function switch_view(name, opts={})
    901 {
    902 	if (name === DAMUS.current_view) {
    903 		log_debug("Not switching to '%s', we are already there", name)
    904 		return
    905 	}
    906 
    907 	const last = get_current_view()
    908 	if (!last) {
    909 		// render initial
    910 		DAMUS.current_view = name
    911 		redraw_timeline_events(DAMUS, name)
    912 		return
    913 	}
    914 
    915 	log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view)
    916 
    917 	DAMUS.current_view = name
    918 	const current = get_current_view()
    919 	const last_el = get_view_el(last.name)
    920 	const current_el = get_view_el(current.name)
    921 
    922 	if (last_el)
    923 		last_el.classList.add("hide");
    924 
    925 	// TODO accomodate views that do not render events
    926 	// TODO find out if having multiple event divs is slow
    927 	//redraw_timeline_events(DAMUS, name)
    928 
    929 	find_node("#nav > div[data-active]").dataset.active = name;
    930 
    931 	if (current_el)
    932 		current_el.classList.remove("hide");
    933 }
    934 
    935 function load_our_relays(our_pubkey, pool, ev) {
    936 	if (ev.pubkey != our_pubkey)
    937 		return
    938 
    939 	let relays
    940 	try {
    941 		relays = JSON.parse(ev.content)
    942 	} catch (e) {
    943 		log_debug("error loading relays", e)
    944 		return
    945 	}
    946 
    947 	for (const relay of Object.keys(relays)) {
    948 		if (!pool.has(relay)) {
    949 			log_debug("adding relay", relay)
    950 			pool.add(relay)
    951 		}
    952 	}
    953 }
    954 
    955 function load_our_contacts(contacts, our_pubkey, ev) {
    956 	if (ev.pubkey !== our_pubkey)
    957 		return
    958 
    959 	contacts.event = ev
    960 
    961 	for (const tag of ev.tags) {
    962 		if (tag.length > 1 && tag[0] === "p") {
    963 			contacts.friends.add(tag[1])
    964 		}
    965 	}
    966 }
    967 
    968 function handle_profiles_loaded(ids, model, view, relay) {
    969 	// stop asking for profiles
    970 	model.pool.unsubscribe(ids.profiles, relay)
    971 
    972 	//redraw_events(model, view)
    973 	redraw_my_pfp(model)
    974 
    975 	const prefix = difficulty_to_prefix(model.pow)
    976 	const fofs = Array.from(model.contacts.friend_of_friends)
    977 	const standard_kinds = [1,42,5,6,7]
    978 	const now = new Date().getTime() / 1000;
    979 
    980 	let pow_filter = {kinds: standard_kinds, limit: 50, until: now}
    981 	if (model.pow > 0)
    982 		pow_filter.ids = [ prefix ]
    983 
    984 	let explore_filters = [ pow_filter ]
    985 
    986 	if (fofs.length > 0) {
    987 		explore_filters.push({kinds: standard_kinds, authors: fofs, limit: 50})
    988 	}
    989 
    990 	model.pool.subscribe(ids.explore, explore_filters, relay)
    991 }
    992 
    993 function redraw_my_pfp(model, force = false) {
    994 	const p = model.profiles[model.pubkey]
    995 	if (!p) return;
    996 	const html = render_pfp(model.pubkey, p);
    997 	const el = document.querySelector(".my-userpic")
    998 	if (!force && el.dataset.loaded) return;
    999 	el.dataset.loaded = true;
   1000 	el.innerHTML = html;
   1001 }
   1002 
   1003 function debounce(f, interval) {
   1004 	let timer = null;
   1005 	let first = true;
   1006 
   1007 	return (...args) => {
   1008 		clearTimeout(timer);
   1009 		return new Promise((resolve) => {
   1010 			timer = setTimeout(() => resolve(f(...args)), first? 0 : interval);
   1011 			first = false
   1012 		});
   1013 	};
   1014 }
   1015 
   1016 // load profiles after comment notes are loaded
   1017 function handle_comments_loaded(ids, model, events, relay)
   1018 {
   1019 	const pubkeys = events.reduce((s, ev) => {
   1020 		s.add(ev.pubkey)
   1021 		for (const tag of ev.tags) {
   1022 			if (tag.length >= 2 && tag[0] === "p") {
   1023 				if (!model.profile_events[tag[1]])
   1024 					s.add(tag[1])
   1025 			}
   1026 		}
   1027 		return s
   1028 	}, new Set())
   1029 	const authors = Array.from(pubkeys)
   1030 
   1031 	// load profiles and noticed chatrooms
   1032 	const profile_filter = {kinds: [0,3], authors: authors}
   1033 
   1034 	let filters = []
   1035 
   1036 	if (authors.length > 0)
   1037 		filters.push(profile_filter)
   1038 
   1039 	if (filters.length === 0) {
   1040 		log_debug("No profiles filters to request...")
   1041 		return
   1042 	}
   1043 
   1044 	//console.log("subscribe", profiles_id, filter, relay)
   1045 	log_debug("subscribing to profiles on %s", relay.url)
   1046 	model.pool.subscribe(ids.profiles, filters, relay)
   1047 }
   1048 
   1049 function redraw_events(damus, view) {
   1050 	//log_debug("redrawing events for", view)
   1051 	view.rendered = new Set()
   1052 
   1053 	const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`)
   1054 	events_el.innerHTML = render_events(damus, view)
   1055 }
   1056 
   1057 function redraw_timeline_events(damus, name) {
   1058 	const view = DAMUS.views[name]
   1059 	const events_el = damus.view_el.querySelector(`#${name}-view > .events`)
   1060 
   1061 	if (view.events.length > 0) {
   1062 		redraw_events(damus, view)
   1063 	} else {
   1064 		events_el.innerHTML = render_loading_spinner()
   1065 	}
   1066 }
   1067 
   1068 async function send_post() {
   1069 	const input_el = document.querySelector("#post-input")
   1070 	const cw_el = document.querySelector("#content-warning-input")
   1071 
   1072 	const cw = cw_el.value
   1073 	const content = input_el.value
   1074 	const created_at = Math.floor(new Date().getTime() / 1000)
   1075 	const kind = 1
   1076 	const tags = cw ? [["content-warning", cw]] : []
   1077 	const pubkey = await get_pubkey()
   1078 	const {pool} = DAMUS
   1079 
   1080 	let post = { pubkey, tags, content, created_at, kind }
   1081 
   1082 	post.id = await nostrjs.calculate_id(post)
   1083 	post = await sign_event(post)
   1084 
   1085 	pool.send(["EVENT", post])
   1086 
   1087 	input_el.value = ""
   1088 	cw_el.value = ""
   1089 	post_input_changed(input_el)
   1090 }
   1091 
   1092 async function sign_event(ev) {
   1093 	if (window.nostr && window.nostr.signEvent) {
   1094 		const signed = await window.nostr.signEvent(ev)
   1095 		if (typeof signed === 'string') {
   1096 			ev.sig = signed
   1097 			return ev
   1098 		}
   1099 		return signed
   1100 	}
   1101 
   1102 	const privkey = get_privkey()
   1103 	ev.sig = await sign_id(privkey, ev.id)
   1104 	return ev
   1105 }
   1106 
   1107 function determine_event_refs_positionally(pubkeys, ids)
   1108 {
   1109 	if (ids.length === 1)
   1110 		return {root: ids[0], reply: ids[0], pubkeys}
   1111 	else if (ids.length >= 2)
   1112 		return {root: ids[0], reply: ids[1], pubkeys}
   1113 
   1114 	return {pubkeys}
   1115 }
   1116 
   1117 function determine_event_refs(tags) {
   1118 	let positional_ids = []
   1119 	let pubkeys = []
   1120 	let root
   1121 	let reply
   1122 	let i = 0
   1123 
   1124 	for (const tag of tags) {
   1125 		if (tag.length >= 4 && tag[0] == "e") {
   1126 			positional_ids.push(tag[1])
   1127 			if (tag[3] === "root") {
   1128 				root = tag[1]
   1129 			} else if (tag[3] === "reply") {
   1130 				reply = tag[1]
   1131 			}
   1132 		} else if (tag.length >= 2 && tag[0] == "e") {
   1133 			positional_ids.push(tag[1])
   1134 		} else if (tag.length >= 2 && tag[0] == "p") {
   1135 			pubkeys.push(tag[1])
   1136 		}
   1137 
   1138 		i++
   1139 	}
   1140 
   1141 	if (!(root && reply) && positional_ids.length > 0)
   1142 		return determine_event_refs_positionally(pubkeys, positional_ids)
   1143 
   1144 	/*
   1145 	if (reply && !root)
   1146 		root = reply
   1147 		*/
   1148 
   1149 	return {root, reply, pubkeys}
   1150 }
   1151 
   1152 function* yield_etags(tags)
   1153 {
   1154 	for (const tag of tags) {
   1155 		if (tag.length >= 2 && tag[0] === "e")
   1156 			yield tag
   1157 	}
   1158 }
   1159 
   1160 function expand_thread(id, reply_id) {
   1161 	const view = get_current_view()
   1162 	const root_id = get_thread_root_id(DAMUS, id)
   1163 	if (!root_id) {
   1164 		log_debug("could not get root_id for", DAMUS.all_events[id])
   1165 		return
   1166 	}
   1167 
   1168 	view.expanded.add(reply_id)
   1169 	view.depths[root_id] = get_thread_max_depth(DAMUS, view, root_id) + 1
   1170 
   1171 	redraw_events(DAMUS, view)
   1172 }
   1173 
   1174 function get_thread_root_id(damus, id)
   1175 {
   1176 	const ev = damus.all_events[id]
   1177 	if (!ev) {
   1178 		log_debug("expand_thread: no event found?", id)
   1179 		return null
   1180 	}
   1181 
   1182 	return ev.refs && ev.refs.root
   1183 }
   1184 
   1185 function get_default_max_depth(damus, view)
   1186 {
   1187 	return view.max_depth || damus.max_depth
   1188 }
   1189 
   1190 function get_thread_max_depth(damus, view, root_id)
   1191 {
   1192 	if (!view.depths[root_id])
   1193 		return get_default_max_depth(damus, view)
   1194 
   1195 	return view.depths[root_id]
   1196 }
   1197 
   1198 function delete_post_confirm(evid) {
   1199 	if (!confirm("Are you sure you want to delete this post?"))
   1200 		return
   1201 
   1202 	const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim()
   1203 
   1204 	if (reason.toLowerCase() === "cancel")
   1205 		return
   1206 
   1207 	delete_post(evid, reason)
   1208 }
   1209 
   1210 function shouldnt_render_event(our_pk, view, ev, opts) {
   1211 	return !opts.is_composing &&
   1212 		!view.expanded.has(ev.id) &&
   1213 		view.rendered.has(ev.id)
   1214 }
   1215 
   1216 function press_logout() {
   1217 	if (confirm("Are you sure you want to sign out?")) {
   1218 		localStorage.clear();
   1219 		const url = new URL(location.href)
   1220 		url.searchParams.delete("pk")
   1221 		window.location.href = url.toString()
   1222 	}
   1223 }
   1224 
   1225 async function delete_post(id, reason)
   1226 {
   1227 	const ev = DAMUS.all_events[id]
   1228 	if (!ev)
   1229 		return
   1230 
   1231 	const pubkey = await get_pubkey()
   1232 	let del = await create_deletion_event(pubkey, id, reason)
   1233 	console.log("deleting", ev)
   1234 	broadcast_event(del)
   1235 }
   1236 
   1237 function get_reactions(model, evid)
   1238 {
   1239 	const reactions_set = model.reactions_to[evid]
   1240 	if (!reactions_set)
   1241 		return ""
   1242 
   1243 	let reactions = []
   1244 	for (const id of reactions_set.keys()) {
   1245 		if (is_deleted(model, id))
   1246 			continue
   1247 		const reaction = model.all_events[id]
   1248 		if (!reaction)
   1249 			continue
   1250 		reactions.push(reaction)
   1251 	}
   1252 
   1253 	const groups = reactions.reduce((grp, r) => {
   1254 		const e = get_reaction_emoji(r)
   1255 		grp[e] = grp[e] || {}
   1256 		grp[e][r.pubkey] = r
   1257 		return grp
   1258 	}, {})
   1259 
   1260 	return groups
   1261 }
   1262 
   1263 function close_reply() {
   1264 	const modal = document.querySelector("#reply-modal")
   1265 	modal.classList.add("closed");
   1266 }
   1267 
   1268 function gather_reply_tags(pubkey, from) {
   1269 	let tags = []
   1270 	let ids = new Set()
   1271 
   1272 	if (from.refs && from.refs.root) {
   1273 		tags.push(["e", from.refs.root, "", "root"])
   1274 		ids.add(from.refs.root)
   1275 	}
   1276 
   1277 	tags.push(["e", from.id, "", "reply"])
   1278 	ids.add(from.id)
   1279 
   1280 	for (const tag of from.tags) {
   1281 		if (tag.length >= 2) {
   1282 			if (tag[0] === "p" && tag[1] !== pubkey) {
   1283 				if (!ids.has(tag[1])) {
   1284 					tags.push(["p", tag[1]])
   1285 					ids.add(tag[1])
   1286 				}
   1287 			}
   1288 		}
   1289 	}
   1290 	if (from.pubkey !== pubkey && !ids.has(from.pubkey)) {
   1291 		tags.push(["p", from.pubkey])
   1292 	}
   1293 	return tags
   1294 }
   1295 
   1296 async function create_deletion_event(pubkey, target, content="")
   1297 {
   1298 	const created_at = Math.floor(new Date().getTime() / 1000)
   1299 	let kind = 5
   1300 
   1301 	const tags = [["e", target]]
   1302 	let del = { pubkey, tags, content, created_at, kind }
   1303 
   1304 	del.id = await nostrjs.calculate_id(del)
   1305 	del = await sign_event(del)
   1306 	return del
   1307 }
   1308 
   1309 async function create_reply(pubkey, content, from) {
   1310 	const tags = gather_reply_tags(pubkey, from)
   1311 	const created_at = Math.floor(new Date().getTime() / 1000)
   1312 	let kind = from.kind
   1313 
   1314 	// convert emoji replies into reactions
   1315 	if (is_valid_reaction_content(content))
   1316 		kind = 7
   1317 
   1318 	let reply = { pubkey, tags, content, created_at, kind }
   1319 
   1320 	reply.id = await nostrjs.calculate_id(reply)
   1321 	reply = await sign_event(reply)
   1322 	return reply
   1323 }
   1324 
   1325 function get_tag_event(tag)
   1326 {
   1327 	if (tag.length < 2)
   1328 		return null
   1329 
   1330 	if (tag[0] === "e")
   1331 		return DAMUS.all_events[tag[1]]
   1332 
   1333 	if (tag[0] === "p")
   1334 		return DAMUS.all_events[DAMUS.profile_events[tag[1]]]
   1335 
   1336 	return null
   1337 }
   1338 
   1339 async function broadcast_related_events(ev)
   1340 {
   1341 	ev.tags
   1342 		.reduce((evs, tag) => {
   1343 			// cap it at something sane
   1344 			if (evs.length >= 5)
   1345 				return evs
   1346 			const ev = get_tag_event(tag)
   1347 			if (!ev)
   1348 				return evs
   1349 			insert_event_sorted(evs, ev) // for uniqueness
   1350 			return evs
   1351 		}, [])
   1352 		.forEach((ev, i) => {
   1353 			// so we don't get rate limited
   1354 			setTimeout(() => {
   1355 				log_debug("broadcasting related event", ev)
   1356 				broadcast_event(ev)
   1357 			}, (i+1)*1200)
   1358 		})
   1359 }
   1360 
   1361 function broadcast_event(ev) {
   1362 	DAMUS.pool.send(["EVENT", ev])
   1363 }
   1364 
   1365 async function send_reply(content, replying_to)
   1366 {
   1367 	const ev = DAMUS.all_events[replying_to]
   1368 	if (!ev)
   1369 		return
   1370 
   1371 	const pubkey = await get_pubkey()
   1372 	let reply = await create_reply(pubkey, content, ev)
   1373 
   1374 	broadcast_event(reply)
   1375 	broadcast_related_events(reply)
   1376 }
   1377 
   1378 async function do_send_reply() {
   1379 	const modal = document.querySelector("#reply-modal")
   1380 	const replying_to = modal.querySelector("#replying-to")
   1381 
   1382 	const evid = replying_to.dataset.evid
   1383 	const reply_content_el = document.querySelector("#reply-content")
   1384 	const content = reply_content_el.value
   1385 
   1386 	await send_reply(content, evid)
   1387 
   1388 	reply_content_el.value = ""
   1389 
   1390 	close_reply()
   1391 }
   1392 
   1393 function get_local_state(key) {
   1394 	if (DAMUS[key] != null)
   1395 		return DAMUS[key]
   1396 
   1397 	return localStorage.getItem(key)
   1398 }
   1399 
   1400 function set_local_state(key, val) {
   1401 	DAMUS[key] = val
   1402 	localStorage.setItem(key, val)
   1403 }
   1404 
   1405 function get_qs(loc=location.href) {
   1406 	return new URL(loc).searchParams
   1407 }
   1408 
   1409 async function get_nip05_pubkey(email) {
   1410 	const [user, host] = email.split("@")
   1411 	const url = `https://${host}/.well-known/nostr.json?name=${user}`
   1412 
   1413 	try {
   1414 		const res = await fetch(url)
   1415 		const json = await res.json()
   1416 
   1417 		log_debug("nip05 data", json)
   1418 		return json.names[user]
   1419 	} catch (e) {
   1420 		log_error("fetching nip05 entry for %s", email, e)
   1421 		throw e
   1422 	}
   1423 }
   1424 
   1425 async function handle_pubkey(pubkey) {
   1426 	if (pubkey[0] === "n")
   1427 		pubkey = bech32_decode(pubkey)
   1428 
   1429 	if (pubkey.includes("@"))
   1430 		pubkey = await get_nip05_pubkey(pubkey)
   1431 
   1432 	set_local_state('pubkey', pubkey)
   1433 
   1434 	return pubkey
   1435 }
   1436 
   1437 async function get_pubkey() {
   1438 	let pubkey = get_local_state('pubkey')
   1439 
   1440 	// qs pk overrides stored key
   1441 	const qs_pk = get_qs().get("pk")
   1442 	if (qs_pk)
   1443 		return await handle_pubkey(qs_pk)
   1444 
   1445 	if (pubkey)
   1446 		return pubkey
   1447 
   1448 	console.log("window.nostr", window.nostr)
   1449 	if (window.nostr && window.nostr.getPublicKey) {
   1450 		console.log("calling window.nostr.getPublicKey()...")
   1451 		const pubkey = await window.nostr.getPublicKey()
   1452 		console.log("got %s pubkey from nos2x", pubkey)
   1453 		return await handle_pubkey(pubkey)
   1454 	}
   1455 
   1456 	pubkey = prompt("Enter nostr id (eg: jb55@jb55.com) or pubkey (hex or npub)")
   1457 
   1458 	if (!pubkey)
   1459 		throw new Error("Need pubkey to continue")
   1460 
   1461 	return await handle_pubkey(pubkey)
   1462 }
   1463 
   1464 function get_privkey() {
   1465 	let privkey = get_local_state('privkey')
   1466 
   1467 	if (privkey)
   1468 		return privkey
   1469 
   1470 	if (!privkey)
   1471 		privkey = prompt("Enter private key")
   1472 
   1473 	if (!privkey)
   1474 		throw new Error("can't get privkey")
   1475 
   1476 	if (privkey[0] === "n") {
   1477 		privkey = bech32_decode(privkey)
   1478 	}
   1479 
   1480 	set_local_state('privkey', privkey)
   1481 
   1482 	return privkey
   1483 }
   1484 
   1485 async function sign_id(privkey, id)
   1486 {
   1487 	//const digest = nostrjs.hex_decode(id)
   1488 	const sig = await nobleSecp256k1.schnorr.sign(id, privkey)
   1489 	return nostrjs.hex_encode(sig)
   1490 }
   1491 
   1492 function reply_to(evid) {
   1493 	const modal = document.querySelector("#reply-modal")
   1494 	const replybox = modal.querySelector("#reply-content")
   1495 	modal.classList.remove("closed")
   1496 	const replying_to = modal.querySelector("#replying-to")
   1497 
   1498 	replying_to.dataset.evid = evid
   1499 
   1500 	const ev = DAMUS.all_events[evid]
   1501 	const view = get_current_view()
   1502 	replying_to.innerHTML = render_event(DAMUS, view, ev, {is_composing: true, nobar: true, max_depth: 1})
   1503 
   1504 	replybox.focus()
   1505 }
   1506 
   1507 function convert_quote_blocks(content, show_media)
   1508 {
   1509 	const split = content.split("\n")
   1510 	let blockin = false
   1511 	return split.reduce((str, line) => {
   1512 		if (line !== "" && line[0] === '>') {
   1513 			if (!blockin) {
   1514 				str += "<span class='quote'>"
   1515 				blockin = true
   1516 			}
   1517 			str += linkify(sanitize(line.slice(1)), show_media)
   1518 		} else {
   1519 			if (blockin) {
   1520 				blockin = false
   1521 				str += "</span>"
   1522 			}
   1523 			str += linkify(sanitize(line), show_media)
   1524 		}
   1525 		return str + "<br/>"
   1526 	}, "")
   1527 }
   1528 
   1529 function get_content_warning(tags)
   1530 {
   1531 	for (const tag of tags) {
   1532 		if (tag.length >= 1 && tag[0] === "content-warning")
   1533 			return sanitize(tag[1]) || ""
   1534 	}
   1535 
   1536 	return null
   1537 }
   1538 
   1539 function toggle_content_warning(el)
   1540 {
   1541 	const id = el.id.split("_")[1]
   1542 	const ev = DAMUS.all_events[id]
   1543 
   1544 	if (!ev) {
   1545 		log_debug("could not find content-warning event", id)
   1546 		return
   1547 	}
   1548 
   1549 	DAMUS.cw_open[id] = el.open
   1550 }
   1551 
   1552 function format_content(ev, show_media)
   1553 {
   1554 	if (ev.kind === 7) {
   1555 		if (ev.content === "" || ev.content === "+")
   1556 			return "❤️"
   1557 		return sanitize(ev.content.trim())
   1558 	}
   1559 
   1560 	const content = ev.content.trim()
   1561 	const body = convert_quote_blocks(content, show_media)
   1562 
   1563 	let cw = get_content_warning(ev.tags)
   1564 	if (cw !== null) {
   1565 		let cwHTML = "Content Warning"
   1566 		if (cw === "") {
   1567 			cwHTML += "."
   1568 		} else {
   1569 			cwHTML += `: "<span>${cw}</span>".`
   1570 		}
   1571 		const open = !!DAMUS.cw_open[ev.id]? "open" : ""
   1572 		return `
   1573 		<details ontoggle="toggle_content_warning(this)" class="cw" id="cw_${ev.id}" ${open}>
   1574 		  <summary class="event-message">${cwHTML}</summary>
   1575 		  ${body}
   1576 		</details>
   1577 		`
   1578 	}
   1579 
   1580 	return body
   1581 }
   1582 
   1583 function sanitize(content)
   1584 {
   1585 	if (!content)
   1586 		return ""
   1587 	return DOMPurify.sanitize(content)
   1588 }
   1589 
   1590 function robohash(pk) {
   1591 	return "https://robohash.org/" + pk
   1592 }
   1593 
   1594 function get_picture(pk, profile) {
   1595 	if (!profile)
   1596 		return robohash(pk)
   1597 	if (profile.resolved_picture)
   1598 		return profile.resolved_picture
   1599 	profile.resolved_picture = profile.picture || robohash(pk)
   1600 	return profile.resolved_picture
   1601 }
   1602 
   1603 function passes_spam_filter(contacts, ev, pow)
   1604 {
   1605 	if (contacts.friend_of_friends.has(ev.pubkey))
   1606 		return true
   1607 
   1608 	return ev.pow >= pow
   1609 }
   1610