damus.io

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

damus.js (47263B)


      1 
      2 let DAMUS
      3 
      4 function uuidv4() {
      5   return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
      6     (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
      7   );
      8 }
      9 
     10 function insert_event_sorted(evs, new_ev) {
     11         for (let i = 0; i < evs.length; i++) {
     12                 const ev = evs[i]
     13 
     14                 if (new_ev.id === ev.id) {
     15                         return false
     16                 }
     17 
     18                 if (new_ev.created_at > ev.created_at) {
     19                         evs.splice(i, 0, new_ev)
     20                         return true
     21                 }
     22         }
     23 
     24         evs.push(new_ev)
     25         return true
     26 }
     27 
     28 function init_contacts() {
     29 	return {
     30 		event: null,
     31 		friends: new Set(),
     32 		friend_of_friends: new Set(),
     33 	}
     34 }
     35 
     36 function init_home_model() {
     37 	return {
     38 		done_init: false,
     39 		loading: true,
     40 		notifications: 0,
     41 		rendered: {},
     42 		all_events: {},
     43 		expanded: new Set(),
     44 		reactions_to: {},
     45 		events: [],
     46 		chatrooms: {},
     47 		deletions: {},
     48 		cw_open: {},
     49 		deleted: {},
     50 		profiles: {},
     51 		profile_events: {},
     52 		last_event_of_kind: {},
     53 		contacts: init_contacts()
     54 	}
     55 }
     56 
     57 const BOOTSTRAP_RELAYS = [
     58 	"wss://relay.damus.io",
     59 	"wss://nostr-relay.wlvs.space",
     60 	"wss://nostr-pub.wellorder.net"
     61 ]
     62 
     63 function update_favicon(path)
     64 {
     65 	let link = document.querySelector("link[rel~='icon']");
     66 	const head = document.getElementsByTagName('head')[0]
     67 
     68 	if (!link) {
     69 		link = document.createElement('link');
     70 		link.rel = 'icon';
     71 		head.appendChild(link);
     72 	}
     73 
     74 	link.href = path;
     75 }
     76 
     77 function update_title(model) {
     78 	if (document.visibilityState === 'visible')
     79 		model.notifications = 0
     80 	if (model.notifications === 0) {
     81 		document.title = "Damus"
     82 		update_favicon("img/damus.svg")
     83 	} else {
     84 		document.title = `(${model.notifications}) Damus`
     85 		update_favicon("img/damus_notif.svg")
     86 	}
     87 }
     88 
     89 function notice_chatroom(state, id)
     90 {
     91 	if (!state.chatrooms[id])
     92 		state.chatrooms[id] = {}
     93 }
     94 
     95 async function damus_web_init()
     96 {
     97 	const model = init_home_model()
     98 	DAMUS = model
     99 	model.pubkey = await get_pubkey()
    100 	if (!model.pubkey)
    101 		return
    102 	const {RelayPool} = nostrjs
    103 	const pool = RelayPool(BOOTSTRAP_RELAYS)
    104 	const now = (new Date().getTime()) / 1000
    105 
    106 	const ids = {
    107 		comments: "comments",//uuidv4(),
    108 		profiles: "profiles",//uuidv4(),
    109 		refevents: "refevents",//uuidv4(),
    110 		account: "account",//uuidv4(),
    111 		home: "home",//uuidv4(),
    112 		contacts: "contacts",//uuidv4(),
    113 		notifications: "notifications",//uuidv4(),
    114 		dms: "dms",//uuidv4(),
    115 	}
    116 
    117 	model.pool = pool
    118 	model.view_el = document.querySelector("#view")
    119 	redraw_home_view(model)
    120 
    121 	document.addEventListener('visibilitychange', () => {
    122 		update_title(model)
    123 	})
    124 
    125 	pool.on('open', (relay) => {
    126 		//let authors = followers
    127 		// TODO: fetch contact list
    128 		log_debug("relay connected", relay.url)
    129 
    130 		if (!model.done_init) {
    131 			model.loading = false
    132 
    133 			send_initial_filters(ids.account, model.pubkey, relay)
    134 		} else {
    135 			send_home_filters(ids, model, relay)
    136 		}
    137 		//relay.subscribe(comments_id, {kinds: [1,42], limit: 100})
    138 	});
    139 
    140 	pool.on('event', (relay, sub_id, ev) => {
    141 		handle_home_event(ids, model, relay, sub_id, ev)
    142 	})
    143 
    144 	pool.on('eose', async (relay, sub_id) => {
    145 		if (sub_id === ids.home) {
    146 			handle_comments_loaded(ids.profiles, model, relay)
    147 		} else if (sub_id === ids.profiles) {
    148 			handle_profiles_loaded(ids.profiles, model, relay)
    149 		}
    150 	})
    151 
    152 	return pool
    153 }
    154 
    155 function process_reaction_event(model, ev)
    156 {
    157 	if (!is_valid_reaction_content(ev.content))
    158 		return
    159 
    160 	let last = {}
    161 
    162 	for (const tag of ev.tags) {
    163 		if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"))
    164 			last[tag[0]] = tag[1]
    165 	}
    166 
    167 	if (last.e) {
    168 		model.reactions_to[last.e] = model.reactions_to[last.e] || new Set()
    169 		model.reactions_to[last.e].add(ev.id)
    170 	}
    171 }
    172 
    173 function process_chatroom_event(model, ev)
    174 {
    175 	try {
    176 		model.chatrooms[ev.id] = JSON.parse(ev.content)
    177 	} catch (err) {
    178 		log_debug("error processing chatroom creation event", ev, err)
    179 	}
    180 }
    181 
    182 function process_json_content(ev)
    183 {
    184 	try {
    185 		ev.json_content = JSON.parse(ev.content)
    186 	} catch(e) {
    187 		log_debug("error parsing json content for", ev)
    188 	}
    189 }
    190 
    191 function process_deletion_event(model, ev)
    192 {
    193 	for (const tag of ev.tags) {
    194 		if (tag.length >= 2 && tag[0] === "e") {
    195 			const evid = tag[1]
    196 
    197 			// we've already recorded this one as a valid deleted event
    198 			// we can just ignore it
    199 			if (model.deleted[evid])
    200 				continue
    201 
    202 			let ds = model.deletions[evid] = (model.deletions[evid] || new Set())
    203 
    204 			// add the deletion event id to the deletion set of this event
    205 			// we will use this to determine if this event is valid later in
    206 			// case we don't have the deleted event yet.
    207 			ds.add(ev.id)
    208 		}
    209 	}
    210 }
    211 
    212 function is_deleted(model, evid)
    213 {
    214 	// we've already know it's deleted
    215 	if (model.deleted[evid])
    216 		return model.deleted[evid]
    217 
    218 	const ev = model.all_events[evid]
    219 	if (!ev)
    220 		return false
    221 
    222 	// all deletion events
    223 	const ds = model.deletions[ev.id]
    224 	if (!ds)
    225 		return false
    226 
    227 	// find valid deletion events
    228 	for (const id of ds.keys()) {
    229 		const d_ev = model.all_events[id]
    230 		if (!d_ev)
    231 			continue
    232 
    233 		// only allow deletes from the user who created it
    234 		if (d_ev.pubkey === ev.pubkey) {
    235 			model.deleted[ev.id] = d_ev
    236 			log_debug("received deletion for", ev)
    237 			// clean up deletion data that we don't need anymore
    238 			delete model.deletions[ev.id]
    239 			return true
    240 		} else {
    241 			log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`)
    242 		}
    243 	}
    244 
    245 	return false
    246 }
    247 
    248 function process_event(model, ev)
    249 {
    250 	ev.refs = determine_event_refs(ev.tags)
    251 	const notified = was_pubkey_notified(model.pubkey, ev)
    252 	ev.notified = notified
    253 
    254 	if (ev.kind === 7)
    255 		process_reaction_event(model, ev)
    256 	else if (ev.kind === 42 && ev.refs && ev.refs.root)
    257 		notice_chatroom(model, ev.refs.root)
    258 	else if (ev.kind === 40)
    259 		process_chatroom_event(model, ev)
    260 	else if (ev.kind === 6)
    261 		process_json_content(ev)
    262 	else if (ev.kind === 5)
    263 		process_deletion_event(model, ev)
    264 	else if (ev.kind === 0)
    265 		process_profile_event(model, ev)
    266 
    267 	const last_notified = get_local_state('last_notified_date')
    268 	if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
    269 		set_local_state('last_notified_date', new Date().getTime())
    270 		model.notifications++
    271 		update_title(model)
    272 	}
    273 }
    274 
    275 function was_pubkey_notified(pubkey, ev)
    276 {
    277 	if (!(ev.kind === 1 || ev.kind === 42))
    278 		return false
    279 
    280 	if (ev.pubkey === pubkey)
    281 		return false
    282 
    283 	for (const tag of ev.tags) {
    284 		if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
    285 			return true
    286 	}
    287 
    288 	return false
    289 }
    290 
    291 function should_add_to_home(ev)
    292 {
    293 	return ev.kind === 1 || ev.kind === 42 || ev.kind === 6
    294 }
    295 
    296 let rerender_home_timer
    297 function handle_home_event(ids, model, relay, sub_id, ev) {
    298 	// ignore duplicates
    299 	if (model.all_events[ev.id])
    300 		return
    301 
    302 	model.all_events[ev.id] = ev
    303 	process_event(model, ev)
    304 
    305 	switch (sub_id) {
    306 	case ids.home:
    307 		if (should_add_to_home(ev))
    308 			insert_event_sorted(model.events, ev)
    309 
    310 		if (model.realtime) {
    311 			if (rerender_home_timer)
    312 				clearTimeout(rerender_home_timer)
    313 			rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500)
    314 		}
    315 		break;
    316 	case ids.account:
    317 		switch (ev.kind) {
    318 		case 3:
    319 			model.loading = false
    320 			process_contact_event(model, ev)
    321 			model.done_init = true
    322 			model.pool.unsubscribe(ids.account, [relay])
    323 			break
    324 		}
    325 	case ids.profiles:
    326 		break
    327 	}
    328 }
    329 
    330 function process_profile_event(model, ev) {
    331 	const prev_ev = model.profile_events[ev.pubkey]
    332 	if (prev_ev && prev_ev.created_at > ev.created_at)
    333 		return
    334 
    335 	model.profile_events[ev.pubkey] = ev
    336 	try {
    337 		model.profiles[ev.pubkey] = JSON.parse(ev.content)
    338 	} catch(e) {
    339 		log_debug("failed to parse profile contents", ev)
    340 	}
    341 }
    342 
    343 function send_initial_filters(account_id, pubkey, relay) {
    344 	const filter = {authors: [pubkey], kinds: [3], limit: 1}
    345 	//console.log("sending initial filter", filter)
    346 	relay.subscribe(account_id, filter)
    347 }
    348 
    349 function send_home_filters(ids, model, relay) {
    350 	const friends = contacts_friend_list(model.contacts)
    351 	friends.push(model.pubkey)
    352 
    353 	const contacts_filter = {kinds: [0], authors: friends}
    354 	const dms_filter = {kinds: [4], limit: 500}
    355 	const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500}
    356 	const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500}
    357 	const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100}
    358 
    359 	let home_filters = [home_filter]
    360 	let notifications_filters = [notifications_filter]
    361 	let contacts_filters = [contacts_filter]
    362 	let dms_filters = [dms_filter, our_dms_filter]
    363 
    364 	let last_of_kind = {}
    365 	if (relay) {
    366 		last_of_kind =
    367 			model.last_event_of_kind[relay] =
    368 			model.last_event_of_kind[relay] || {}
    369 	}
    370 
    371         update_filters_with_since(last_of_kind, home_filters)
    372         update_filters_with_since(last_of_kind, contacts_filters)
    373         update_filters_with_since(last_of_kind, notifications_filters)
    374         update_filters_with_since(last_of_kind, dms_filters)
    375 
    376 	const subto = relay? [relay] : undefined
    377 	model.pool.subscribe(ids.home, home_filters, subto)
    378 	model.pool.subscribe(ids.contacts, contacts_filters, subto)
    379 	model.pool.subscribe(ids.notifications, notifications_filters, subto)
    380 	model.pool.subscribe(ids.dms, dms_filters, subto)
    381 }
    382 
    383 function get_since_time(last_event) {
    384 	if (!last_event) {
    385 		return null
    386 	}
    387 
    388 	return last_event.created_at - 60 * 10
    389 }
    390 
    391 function update_filter_with_since(last_of_kind, filter) {
    392 	const kinds = filter.kinds || []
    393 	let initial = null
    394 	let earliest = kinds.reduce((earliest, kind) => {
    395 		const last = last_of_kind[kind]
    396 		let since = get_since_time(last)
    397 
    398 		if (!earliest) {
    399 			if (since === null)
    400 				return null
    401 
    402 			return since
    403 		}
    404 
    405 		if (since === null)
    406 			return earliest
    407 
    408 		return since < earliest ? since : earliest
    409 
    410 	}, initial)
    411 
    412 	if (earliest)
    413 		filter.since = earliest
    414 }
    415 
    416 function update_filters_with_since(last_of_kind, filters) {
    417 	for (const filter of filters) {
    418 		update_filter_with_since(last_of_kind, filter)
    419 	}
    420 }
    421 
    422 function contacts_friend_list(contacts) {
    423 	return Array.from(contacts.friends)
    424 }
    425 
    426 function process_contact_event(model, ev) {
    427 	load_our_contacts(model.contacts, model.pubkey, ev)
    428 	load_our_relays(model.pubkey, model.pool, ev)
    429 	add_contact_if_friend(model.contacts, ev)
    430 }
    431 
    432 function add_contact_if_friend(contacts, ev) {
    433 	if (!contact_is_friend(contacts, ev.pubkey)) {
    434 		return
    435 	}
    436 
    437 	add_friend_contact(contacts, ev)
    438 }
    439 
    440 function contact_is_friend(contacts, pk) {
    441 	return contacts.friends.has(pk)
    442 }
    443 
    444 function add_friend_contact(contacts, contact) {
    445 	contacts.friends[contact.pubkey] = true
    446 
    447         for (const tag of contact.tags) {
    448             if (tag.count >= 2 && tag[0] == "p") {
    449                 contacts.friend_of_friends.add(tag[1])
    450             }
    451         }
    452 }
    453 
    454 function load_our_relays(our_pubkey, pool, ev) {
    455 	if (ev.pubkey != our_pubkey)
    456 		return
    457 
    458 	let relays
    459 	try {
    460 		relays = JSON.parse(ev.content)
    461 	} catch (e) {
    462 		log_debug("error loading relays", e)
    463 		return
    464 	}
    465 
    466 	for (const relay of Object.keys(relays)) {
    467 		log_debug("adding relay", relay)
    468 		if (!pool.has(relay))
    469 			pool.add(relay)
    470 	}
    471 }
    472 
    473 function log_debug(fmt, ...args) {
    474 	console.log("[debug] " + fmt, ...args)
    475 }
    476 
    477 function load_our_contacts(contacts, our_pubkey, ev) {
    478 	if (ev.pubkey !== our_pubkey)
    479 		return
    480 
    481 	contacts.event = ev
    482 
    483 	for (const tag of ev.tags) {
    484 		if (tag.length > 1 && tag[0] === "p") {
    485 			contacts.friends.add(tag[1])
    486 		}
    487 	}
    488 }
    489 
    490 function get_referenced_events(model)
    491 {
    492 	let evset = new Set()
    493 	for (const ev of model.events) {
    494 		for (const tag of ev.tags) {
    495 			if (tag.count >= 2 && tag[0] === "e") {
    496 				const e = tag[1]
    497 				if (!model.all_events[e]) {
    498 					evset.add(e)
    499 				}
    500 			}
    501 		}
    502 	}
    503 	return Array.from(evset)
    504 }
    505 
    506 
    507 function fetch_referenced_events(refevents_id, model, relay) {
    508 
    509 	const ref = df
    510 	model.pool.subscribe(refevents_id, [filter], relay)
    511 }
    512 
    513 function handle_profiles_loaded(profiles_id, model, relay) {
    514 	// stop asking for profiles
    515 	model.pool.unsubscribe(profiles_id, relay)
    516 	model.realtime = true
    517 
    518 	redraw_events(model)
    519 }
    520 
    521 function debounce(f, interval) {
    522 	let timer = null;
    523 	let first = true;
    524 
    525 	return (...args) => {
    526 		clearTimeout(timer);
    527 		return new Promise((resolve) => {
    528 			timer = setTimeout(() => resolve(f(...args)), first? 0 : interval);
    529 			first = false
    530 		});
    531 	};
    532 }
    533 
    534 function get_unknown_chatroom_ids(state)
    535 {
    536 	let chatroom_ids = []
    537 	for (const key of Object.keys(state.chatrooms)) {
    538 		const chatroom = state.chatrooms[key]
    539 		if (chatroom.name === undefined)
    540 			chatroom_ids.push(key)
    541 	}
    542 	return chatroom_ids
    543 }
    544 
    545 // load profiles after comment notes are loaded
    546 function handle_comments_loaded(profiles_id, model, relay)
    547 {
    548 	const pubkeys = model.events.reduce((s, ev) => {
    549 		s.add(ev.pubkey)
    550 		return s
    551 	}, new Set())
    552 	const authors = Array.from(pubkeys)
    553 
    554 	// load profiles and noticed chatrooms
    555 	const chatroom_ids = get_unknown_chatroom_ids(model)
    556 	const profile_filter = {kinds: [0], authors: authors}
    557 	const chatroom_filter = {kinds: [40], ids: chatroom_ids}
    558 
    559 	let filters = [profile_filter, chatroom_filter]
    560 
    561 	const ref_evids = get_referenced_events(model)
    562 	if (ref_evids.length > 0) {
    563 		log_debug("got %d new referenced events to pull after initial load", ref_evids.length)
    564 		filters.push({ids: ref_evids})
    565 		filters.push({"#e": ref_evids})
    566 	}
    567 
    568 	//console.log("subscribe", profiles_id, filter, relay)
    569 	model.pool.subscribe(profiles_id, filters, relay)
    570 }
    571 
    572 function redraw_events(model) {
    573 	//log_debug("rendering home view")
    574 	model.rendered = {}
    575 	model.events_el.innerHTML = render_events(model)
    576 	setup_home_event_handlers(model.events_el)
    577 }
    578 
    579 function setup_home_event_handlers(events_el)
    580 {
    581 	for (const el of events_el.querySelectorAll(".cw"))
    582 		el.addEventListener("toggle", toggle_content_warning.bind(null))
    583 }
    584 
    585 function redraw_home_view(model) {
    586 	model.view_el.innerHTML = render_home_view(model)
    587 	model.events_el = document.querySelector("#events")
    588 	if (model.events.length > 0) {
    589 		redraw_events(model)
    590 	} else {
    591 		model.events_el.innerHTML= `
    592 		<div class="loading-events">
    593 			<span class="loader" title="Loading...">
    594 				<i class="fa-solid fa-fw fa-spin fa-hurricane"
    595 				style="--fa-animation-duration: 0.5s;"></i>
    596 			</span>
    597 		</div>
    598 		`
    599 	}
    600 }
    601 
    602 async function send_post() {
    603 	const input_el = document.querySelector("#post-input")
    604 	const cw_el = document.querySelector("#content-warning-input")
    605 
    606 	const cw = cw_el.value
    607 	const content = input_el.value
    608 	const created_at = Math.floor(new Date().getTime() / 1000)
    609 	const kind = 1
    610 	const tags = cw ? [["content-warning", cw]] : []
    611 	const pubkey = await get_pubkey()
    612 	const {pool} = DAMUS
    613 
    614 	let post = { pubkey, tags, content, created_at, kind }
    615 
    616 	post.id = await nostrjs.calculate_id(post)
    617 	post = await sign_event(post)
    618 
    619 	pool.send(["EVENT", post])
    620 
    621 	input_el.value = ""
    622 	cw_el.value = ""
    623 	post_input_changed(input_el)
    624 }
    625 
    626 async function sign_event(ev) {
    627 	if (window.nostr && window.nostr.signEvent) {
    628 		const signed = await window.nostr.signEvent(ev)
    629 		if (typeof signed === 'string') {
    630 			ev.sig = signed
    631 			return ev
    632 		}
    633 		return signed
    634 	}
    635 
    636 	const privkey = get_privkey()
    637 	ev.sig = await sign_id(privkey, ev.id)
    638 	return ev
    639 }
    640 
    641 function render_home_view(model) {
    642 	return `
    643 	<div id="newpost">
    644 		<div><!-- empty to accomodate profile pic --></div>
    645 		<div>
    646 			<textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea>
    647 			<div class="post-tools">
    648 				<input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/>
    649 				<button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon">
    650 					<i class="fa-solid fa-triangle-exclamation"></i>
    651 				</button>
    652 				<button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button>
    653 			</div>
    654 		</div>
    655 	</div>
    656 	<div id="events"></div>
    657 	`
    658 }
    659 
    660 function post_input_changed(el)
    661 {
    662 	document.querySelector("#post-button").disabled = el.value === ""
    663 }
    664 
    665 function render_home_event(model, ev)
    666 {
    667 	let max_depth = 3
    668 	if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) {
    669 		max_depth = null
    670 	}
    671 
    672 	return render_event(model, ev, {max_depth})
    673 }
    674 
    675 function render_events(model) {
    676 	return model.events
    677 		.filter((ev, i) => i < 140)
    678 		.map((ev) => render_home_event(model, ev)).join("\n")
    679 }
    680 
    681 function determine_event_refs_positionally(pubkeys, ids)
    682 {
    683 	if (ids.length === 1)
    684 		return {root: ids[0], reply: ids[0], pubkeys}
    685 	else if (ids.length >= 2)
    686 		return {root: ids[0], reply: ids[1], pubkeys}
    687 
    688 	return {pubkeys}
    689 }
    690 
    691 function determine_event_refs(tags) {
    692 	let positional_ids = []
    693 	let pubkeys = []
    694 	let root
    695 	let reply
    696 	let i = 0
    697 
    698 	for (const tag of tags) {
    699 		if (tag.length >= 4 && tag[0] == "e") {
    700 			if (tag[3] === "root")
    701 				root = tag[1]
    702 			else if (tag[3] === "reply")
    703 				reply = tag[1]
    704 		} else if (tag.length >= 2 && tag[0] == "e") {
    705 			positional_ids.push(tag[1])
    706 		} else if (tag.length >= 2 && tag[0] == "p") {
    707 			pubkeys.push(tag[1])
    708 		}
    709 
    710 		i++
    711 	}
    712 
    713 	if (!root && !reply && positional_ids.length > 0)
    714 		return determine_event_refs_positionally(pubkeys, positional_ids)
    715 
    716 	return {root, reply, pubkeys}
    717 }
    718 
    719 function render_reply_line_top(has_top_line) {
    720 	const classes = has_top_line ? "" : "invisible"
    721 	return `<div class="line-top ${classes}"></div>`
    722 }
    723 
    724 function render_reply_line_bot() {
    725 	return `<div class="line-bot"></div>`
    726 }
    727 
    728 function can_reply(ev) {
    729 	return ev.kind === 1 || ev.kind === 42
    730 }
    731 
    732 const DEFAULT_PROFILE = {
    733 	name: "anon",
    734 	display_name: "Anonymous",
    735 }
    736 
    737 function render_thread_collapsed(model, reply_ev, opts)
    738 {
    739 	if (opts.is_composing)
    740 		return ""
    741 	return `
    742 	<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed">
    743 		<div class="thread-summary">
    744 		More messages in thread available. Click to expand.
    745 		</div>
    746 	</div>`
    747 }
    748 
    749 function* yield_etags(tags)
    750 {
    751 	for (const tag of tags) {
    752 		if (tag.length >= 2 && tag[0] === "e")
    753 			yield tag
    754 	}
    755 }
    756 
    757 function expand_thread(id) {
    758 	const ev = DAMUS.all_events[id]
    759 	if (ev) {
    760 		for (const tag of yield_etags(ev.tags))
    761 			DAMUS.expanded.add(tag[1])
    762 	}
    763 	DAMUS.expanded.add(id)
    764 	redraw_events(DAMUS)
    765 }
    766 
    767 function render_replied_events(model, ev, opts)
    768 {
    769 	if (!(ev.refs && ev.refs.reply))
    770 		return ""
    771 
    772 	const reply_ev = model.all_events[ev.refs.reply]
    773 	if (!reply_ev)
    774 		return ""
    775 
    776 	opts.replies = opts.replies == null ? 1 : opts.replies + 1
    777 	if (!(opts.max_depth == null || opts.replies < opts.max_depth))
    778 		return render_thread_collapsed(model, reply_ev, opts)
    779 
    780 	opts.is_reply = true
    781 	return render_event(model, reply_ev, opts)
    782 }
    783 
    784 function render_replying_to_chat(model, ev) {
    785 	const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {}
    786 	const roomname = chatroom.name || ev.refs.root || "??"
    787 	const pks = ev.refs.pubkeys || []
    788 	const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
    789 	const to_users = pks.length === 0 ? "" : ` to ${names}`
    790 
    791 	return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
    792 }
    793 
    794 function render_replying_to(model, ev) {
    795 	if (!(ev.refs && ev.refs.reply))
    796 		return ""
    797 
    798 	if (ev.kind === 42)
    799 		return render_replying_to_chat(model, ev)
    800 
    801 	let pubkeys = ev.refs.pubkeys || []
    802 	if (pubkeys.length === 0 && ev.refs.reply) {
    803 		const replying_to = model.all_events[ev.refs.reply]
    804 		if (!replying_to)
    805 			return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>`
    806 
    807 		pubkeys = [replying_to.pubkey]
    808 	}
    809 
    810 	const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
    811 
    812 	return `
    813 	<span class="replying-to small-txt">
    814 		replying to ${names}
    815 	</span>
    816 	`
    817 }
    818 
    819 function delete_post_confirm(evid) {
    820 	if (!confirm("Are you sure you want to delete this post?"))
    821 		return
    822 
    823 	const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim()
    824 
    825 	if (reason.toLowerCase() === "cancel")
    826 		return
    827 
    828 	delete_post(evid, reason)
    829 }
    830 
    831 function render_unknown_event(model, ev) {
    832 	return "Unknown event"
    833 }
    834 
    835 function render_boost(model, ev, opts) {
    836 	//todo validate content
    837 	if (!ev.json_content)
    838 		return render_unknown_event(ev)
    839 	
    840 	//const profile = model.profiles[ev.pubkey]
    841 	opts.is_boost_event = true
    842 	opts.boosted = {
    843 		pubkey: ev.pubkey,
    844 		profile: model.profiles[ev.pubkey]
    845 	}
    846 	return render_event(model, ev.json_content, opts)
    847 	//return `
    848 	//<div class="boost">
    849 	//<div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div>
    850 	//${render_event(model, ev.json_content, opts)}
    851 	//</div>
    852 	//`
    853 }
    854 
    855 function shouldnt_render_event(model, ev, opts) {
    856 	return !opts.is_boost_event &&
    857 		!opts.is_composing &&
    858 		!model.expanded.has(ev.id) &&
    859 		model.rendered[ev.id]
    860 }
    861 
    862 function render_deleted_name() {
    863 	return "???"
    864 }
    865 
    866 function render_deleted_pfp() {
    867 	return `<div class="pfp pfp-normal">😵</div>`
    868 }
    869 
    870 function render_comment_body(model, ev, opts) {
    871 	const can_delete = model.pubkey === ev.pubkey;
    872 	const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev, can_delete)
    873 	const show_media = !opts.is_composing
    874 
    875 	return `
    876 	<div>
    877 	${render_replying_to(model, ev)}
    878 	${render_boosted_by(model, ev, opts)}
    879 	</div>
    880 	<p>
    881 	${format_content(ev, show_media)}
    882 	</p>
    883 	${render_reactions(model, ev)}
    884 	${bar}
    885 	`
    886 }
    887 
    888 function render_boosted_by(model, ev, opts) {
    889 	const b = opts.boosted
    890 	if (!b) {
    891 		return ""
    892 	}
    893 	// TODO encapsulate username as link/button!
    894 	return `
    895 	<div class="boosted-by">Shared by 
    896 		<span class="username" data-pubkey="${b.pubkey}">${render_name_plain(b.pubkey, b.profile)}</span>
    897  	</div>
    898 	`	
    899 }
    900 
    901 function render_deleted_comment_body(ev, deleted) {
    902 	if (deleted.content) {
    903 		const show_media = false
    904 		return `
    905 		<div class="deleted-comment">
    906 			This comment was deleted. Reason:
    907 			<div class="quote">${format_content(deleted, show_media)}</div>
    908 		</div>
    909 		`
    910 	}
    911 	return `<div class="deleted-comment">This comment was deleted</div>`
    912 }
    913 
    914 function press_logout() {
    915 	if (confirm("Are you sure you want to logout?")) {
    916 		localStorage.clear();
    917 		const url = new URL(location.href)
    918 		url.searchParams.delete("pk")
    919 		window.location.href = url.toString()
    920 	}
    921 }
    922 
    923 function render_event(model, ev, opts={}) {
    924 	if (ev.kind === 6)
    925 		return render_boost(model, ev, opts)
    926 	if (shouldnt_render_event(model, ev, opts))
    927 		return ""
    928 	delete opts.is_boost_event
    929 	model.rendered[ev.id] = true
    930 	const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE
    931 	const delta = time_delta(new Date().getTime(), ev.created_at*1000)
    932 
    933 	const has_bot_line = opts.is_reply
    934 	const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
    935 
    936 	const deleted = is_deleted(model, ev.id)
    937 	if (deleted && !opts.is_reply)
    938 		return ""
    939 
    940 	const replied_events = render_replied_events(model, ev, opts)
    941 
    942 	let name = "???"
    943 	if (!deleted) {
    944 		name = render_name_plain(ev.pubkey, profile)
    945 	}
    946 
    947 	const has_top_line = replied_events !== ""
    948 	const border_bottom = has_bot_line ? "" : "bottom-border";
    949 	return `
    950 	${replied_events}
    951 	<div id="ev${ev.id}" class="event ${border_bottom}">
    952 		<div class="userpic">
    953 			${render_reply_line_top(has_top_line)}
    954 			${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
    955 			${reply_line_bot}
    956 		</div>	
    957 		<div class="event-content">
    958 			<div class="info">
    959 				<span class="username" data-pubkey="${ev.pubkey}" data-name="${name}">
    960 					${name}
    961 				</span>
    962 				<span class="timestamp">${delta}</span>
    963 			</div>
    964 			<div class="comment">
    965 				${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)}
    966 			</div>
    967 		</div>
    968 	</div>
    969 	`
    970 }
    971 
    972 function render_pfp(pk, profile, size="normal") {
    973 	const name = render_name_plain(pk, profile)
    974 	return `<img class="pfp" title="${name}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">`
    975 }
    976 
    977 const REACTION_REGEX = /^[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)$/
    978 
    979 const WORD_REGEX=/\w/
    980 function is_emoji(str)
    981 {
    982 	return !WORD_REGEX.test(str) && REACTION_REGEX.test(str)
    983 }
    984 
    985 function is_valid_reaction_content(content)
    986 {
    987 	return content === "+" || content === "" || is_emoji(content)
    988 }
    989 
    990 function get_reaction_emoji(ev) {
    991 	if (ev.content === "+" || ev.content === "")
    992 		return "❤️"
    993 
    994 	return ev.content
    995 }
    996 
    997 function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) {
    998 	const reaction = reactions[our_pubkey]
    999 	if (!reaction) {
   1000 		return `onclick="send_reply('${emoji}', '${reacting_to}')"`
   1001 	} else {
   1002 		return `onclick="delete_post('${reaction.id}')"`
   1003 	}
   1004 }
   1005 
   1006 function render_reaction_group(model, emoji, reactions, reacting_to) {
   1007 	const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk]))
   1008 
   1009 	let onclick = render_react_onclick(model.pubkey, reacting_to.id, emoji, reactions)
   1010 
   1011 	return `
   1012 	<span ${onclick} class="reaction-group clickable">
   1013 	  <span class="reaction-emoji">
   1014 	  ${emoji}
   1015 	  </span>
   1016 	  ${pfps.join("\n")}
   1017 	</span>
   1018 	`
   1019 }
   1020 
   1021 async function delete_post(id, reason)
   1022 {
   1023 	const ev = DAMUS.all_events[id]
   1024 	if (!ev)
   1025 		return
   1026 
   1027 	const pubkey = await get_pubkey()
   1028 	let del = await create_deletion_event(pubkey, id, reason)
   1029 	console.log("deleting", ev)
   1030 	broadcast_event(del)
   1031 }
   1032 
   1033 function render_reaction(model, reaction) {
   1034 	const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE
   1035 	let emoji = reaction.content[0]
   1036 	if (reaction.content === "+" || reaction.content === "")
   1037 		emoji = "❤️"
   1038 
   1039 	return render_pfp(reaction.pubkey, profile, "small")
   1040 }
   1041 
   1042 function get_reactions(model, evid)
   1043 {
   1044 	const reactions_set = model.reactions_to[evid]
   1045 	if (!reactions_set)
   1046 		return ""
   1047 
   1048 	let reactions = []
   1049 	for (const id of reactions_set.keys()) {
   1050 		if (is_deleted(model, id))
   1051 			continue
   1052 		const reaction = model.all_events[id]
   1053 		if (!reaction)
   1054 			continue
   1055 		reactions.push(reaction)
   1056 	}
   1057 
   1058 	const groups = reactions.reduce((grp, r) => {
   1059 		const e = get_reaction_emoji(r)
   1060 		grp[e] = grp[e] || {}
   1061 		grp[e][r.pubkey] = r
   1062 		return grp
   1063 	}, {})
   1064 
   1065 	return groups
   1066 }
   1067 
   1068 function render_reactions(model, ev) {
   1069 	const groups = get_reactions(model, ev.id)
   1070 	let str = ""
   1071 
   1072 	for (const emoji of Object.keys(groups)) {
   1073 		str += render_reaction_group(model, emoji, groups[emoji], ev)
   1074 	}
   1075 
   1076 	return `
   1077 	<div class="reactions">
   1078 	  ${str}
   1079 	</div>
   1080 	`
   1081 }
   1082 
   1083 function close_reply() {
   1084 	const modal = document.querySelector("#reply-modal")
   1085 	modal.classList.add("closed");
   1086 }
   1087 
   1088 function gather_reply_tags(pubkey, from) {
   1089 	let tags = []
   1090 	for (const tag of from.tags) {
   1091 		if (tag.length >= 2) {
   1092 			if (tag[0] === "e") {
   1093 				tags.push(tag)
   1094 			} else if (tag[0] === "p" && tag[1] !== pubkey) {
   1095 				tags.push(tag)
   1096 			}
   1097 		}
   1098 	}
   1099 	tags.push(["e", from.id, "", "reply"])
   1100 	if (from.pubkey !== pubkey)
   1101 		tags.push(["p", from.pubkey])
   1102 	return tags
   1103 }
   1104 
   1105 async function create_deletion_event(pubkey, target, content="")
   1106 {
   1107 	const created_at = Math.floor(new Date().getTime() / 1000)
   1108 	let kind = 5
   1109 
   1110 	const tags = [["e", target]]
   1111 	let del = { pubkey, tags, content, created_at, kind }
   1112 
   1113 	del.id = await nostrjs.calculate_id(del)
   1114 	del = await sign_event(del)
   1115 	return del
   1116 }
   1117 
   1118 async function create_reply(pubkey, content, from) {
   1119 	const tags = gather_reply_tags(pubkey, from)
   1120 	const created_at = Math.floor(new Date().getTime() / 1000)
   1121 	let kind = from.kind
   1122 
   1123 	// convert emoji replies into reactions
   1124 	if (is_valid_reaction_content(content))
   1125 		kind = 7
   1126 
   1127 	let reply = { pubkey, tags, content, created_at, kind }
   1128 
   1129 	reply.id = await nostrjs.calculate_id(reply)
   1130 	reply = await sign_event(reply)
   1131 	return reply
   1132 }
   1133 
   1134 function get_tag_event(tag)
   1135 {
   1136 	if (tag.length < 2)
   1137 		return null
   1138 
   1139 	if (tag[0] === "e")
   1140 		return DAMUS.all_events[tag[1]]
   1141 
   1142 	if (tag[0] === "p")
   1143 		return DAMUS.profile_events[tag[1]]
   1144 
   1145 	return null
   1146 }
   1147 
   1148 async function broadcast_related_events(ev)
   1149 {
   1150 	ev.tags
   1151 		.reduce((evs, tag) => {
   1152 			// cap it at something sane
   1153 			if (evs.length >= 5)
   1154 				return evs
   1155 			const ev = get_tag_event(tag)
   1156 			if (!ev)
   1157 				return evs
   1158 			insert_event_sorted(evs, ev) // for uniqueness
   1159 			return evs
   1160 		}, [])
   1161 		.forEach((ev, i) => {
   1162 			// so we don't get rate limited
   1163 			setTimeout(() => {
   1164 				log_debug("broadcasting related event", ev)
   1165 				broadcast_event(ev)
   1166 			}, (i+1)*1200)
   1167 		})
   1168 }
   1169 
   1170 function broadcast_event(ev) {
   1171 	DAMUS.pool.send(["EVENT", ev])
   1172 }
   1173 
   1174 async function send_reply(content, replying_to)
   1175 {
   1176 	const ev = DAMUS.all_events[replying_to]
   1177 	if (!ev)
   1178 		return
   1179 
   1180 	const pubkey = await get_pubkey()
   1181 	let reply = await create_reply(pubkey, content, ev)
   1182 
   1183 	broadcast_event(reply)
   1184 	broadcast_related_events(reply)
   1185 }
   1186 
   1187 async function do_send_reply() {
   1188 	const modal = document.querySelector("#reply-modal")
   1189 	const replying_to = modal.querySelector("#replying-to")
   1190 
   1191 	const evid = replying_to.dataset.evid
   1192 	const reply_content_el = document.querySelector("#reply-content")
   1193 	const content = reply_content_el.value
   1194 
   1195 	await send_reply(content, evid)
   1196 
   1197 	reply_content_el.value = ""
   1198 
   1199 	close_reply()
   1200 }
   1201 
   1202 function bech32_decode(pubkey) {
   1203 	const decoded = bech32.decode(pubkey)
   1204 	const bytes = fromWords(decoded.words)
   1205 	return nostrjs.hex_encode(bytes)
   1206 }
   1207 
   1208 function get_local_state(key) {
   1209 	if (DAMUS[key] != null)
   1210 		return DAMUS[key]
   1211 
   1212 	return localStorage.getItem(key)
   1213 }
   1214 
   1215 function set_local_state(key, val) {
   1216 	DAMUS[key] = val
   1217 	localStorage.setItem(key, val)
   1218 }
   1219 
   1220 function get_qs(loc=location.href) {
   1221 	return new URL(loc).searchParams
   1222 }
   1223 
   1224 function handle_pubkey(pubkey) {
   1225 	if (pubkey[0] === "n")
   1226 		pubkey = bech32_decode(pubkey)
   1227 
   1228 	set_local_state('pubkey', pubkey)
   1229 
   1230 	return pubkey
   1231 }
   1232 
   1233 async function get_pubkey() {
   1234 	let pubkey = get_local_state('pubkey')
   1235 
   1236 	// qs pk overrides stored key
   1237 	const qs_pk = get_qs().get("pk")
   1238 	if (qs_pk)
   1239 		return handle_pubkey(qs_pk)
   1240 
   1241 	if (pubkey)
   1242 		return pubkey
   1243 
   1244 	if (window.nostr && window.nostr.getPublicKey) {
   1245 		const pubkey = await window.nostr.getPublicKey()
   1246 		console.log("got %s pubkey from nos2x", pubkey)
   1247 		return pubkey
   1248 	}
   1249 
   1250 	pubkey = prompt("Enter pubkey (hex or npub)")
   1251 
   1252 	if (!pubkey)
   1253 		throw new Error("Need pubkey to continue")
   1254 
   1255 	return handle_pubkey(pubkey)
   1256 }
   1257 
   1258 function get_privkey() {
   1259 	let privkey = get_local_state('privkey')
   1260 
   1261 	if (privkey)
   1262 		return privkey
   1263 
   1264 	if (!privkey)
   1265 		privkey = prompt("Enter private key")
   1266 
   1267 	if (!privkey)
   1268 		throw new Error("can't get privkey")
   1269 
   1270 	if (privkey[0] === "n") {
   1271 		privkey = bech32_decode(privkey)
   1272 	}
   1273 
   1274 	set_local_state('privkey', privkey)
   1275 
   1276 	return privkey
   1277 }
   1278 
   1279 async function sign_id(privkey, id)
   1280 {
   1281 	//const digest = nostrjs.hex_decode(id)
   1282 	const sig = await nobleSecp256k1.schnorr.sign(id, privkey)
   1283 	return nostrjs.hex_encode(sig)
   1284 }
   1285 
   1286 function reply_to(evid) {
   1287 	const modal = document.querySelector("#reply-modal")
   1288 	modal.classList.remove("closed")
   1289 	const replying_to = modal.querySelector("#replying-to")
   1290 
   1291 	replying_to.dataset.evid = evid
   1292 	const ev = DAMUS.all_events[evid]
   1293 	replying_to.innerHTML = render_event(DAMUS, ev, {is_composing: true, nobar: true, max_depth: 1})
   1294 }
   1295 
   1296 function render_action_bar(ev, can_delete) {
   1297 	let delete_html = ""
   1298 	if (can_delete)
   1299 		delete_html = `<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"><i class="fa fa-fw fa-trash"></i></a>`
   1300 
   1301 	const groups = get_reactions(DAMUS, ev.id)
   1302 	const like = "❤️"
   1303 	const likes = groups[like] || {}
   1304 	const react_onclick = render_react_onclick(DAMUS.pubkey, ev.id, like, likes)
   1305 	return `
   1306 	<div class="action-bar">
   1307 		<button class="icon" title="Reply" onclick="reply_to('${ev.id}')"><i class="fa fa-fw fa-comment"></i></a>
   1308 		<button class="icon react heart" ${react_onclick} title="Like"><i class="fa fa-fw fa-heart"></i></a>
   1309 		${delete_html}	
   1310 	</div>
   1311 	`
   1312 	//<button class="icon" title="Share" onclick=""><i class="fa fa-fw fa-link"></i></a>
   1313 	//<button class="icon" title="View raw Nostr event." onclick=""><i class="fa-solid fa-fw fa-code"></i></a>-->
   1314 }
   1315 
   1316 const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i
   1317 function is_img_url(path) {
   1318 	return IMG_REGEX.test(path)
   1319 }
   1320 
   1321 const VID_REGEX = /(webm|mp4)$/i
   1322 function is_video_url(path) {
   1323 	return VID_REGEX.test(path)
   1324 }
   1325 
   1326 const URL_REGEX = /(https?:\/\/[^\s]+)[,:)]?(\w|$)/g;
   1327 function linkify(text, show_media) {
   1328 	return text.replace(URL_REGEX, function(url) {
   1329 		const parsed = new URL(url)
   1330 		if (show_media && is_img_url(parsed.pathname))
   1331 			return `
   1332 			<a target="_blank" href="${url}">
   1333 				<img class="inline-img" src="${url}"/>
   1334 			</a>
   1335 			`;
   1336 		else if (show_media && is_video_url(parsed.pathname))
   1337 			return `
   1338 			<video controls class="inline-img" />
   1339 			  <source src="${url}">
   1340 			</video>
   1341 			`;
   1342 		else
   1343 			return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`;
   1344 	})
   1345 }
   1346 
   1347 function convert_quote_blocks(content, show_media)
   1348 {
   1349 	const split = content.split("\n")
   1350 	let blockin = false
   1351 	return split.reduce((str, line) => {
   1352 		if (line !== "" && line[0] === '>') {
   1353 			if (!blockin) {
   1354 				str += "<span class='quote'>"
   1355 				blockin = true
   1356 			}
   1357 			str += linkify(sanitize(line.slice(1)), show_media)
   1358 		} else {
   1359 			if (blockin) {
   1360 				blockin = false
   1361 				str += "</span>"
   1362 			}
   1363 			str += linkify(sanitize(line), show_media)
   1364 		}
   1365 		return str + "<br/>"
   1366 	}, "")
   1367 }
   1368 
   1369 function get_content_warning(tags)
   1370 {
   1371 	for (const tag of tags) {
   1372 		if (tag.length >= 1 && tag[0] === "content-warning")
   1373 			return tag[1] || ""
   1374 	}
   1375 
   1376 	return null
   1377 }
   1378 
   1379 function toggle_content_warning(e)
   1380 {
   1381 	const el = e.target
   1382 	const id = el.id.split("_")[1]
   1383 	const ev = DAMUS.all_events[id]
   1384 
   1385 	if (!ev) {
   1386 		log_debug("could not find content-warning event", id)
   1387 		return
   1388 	}
   1389 
   1390 	DAMUS.cw_open[id] = el.open
   1391 }
   1392 
   1393 function format_content(ev, show_media)
   1394 {
   1395 	if (ev.kind === 7) {
   1396 		if (ev.content === "" || ev.content === "+")
   1397 			return "❤️"
   1398 		return sanitize(ev.content.trim())
   1399 	}
   1400 
   1401 	const content = ev.content.trim()
   1402 	const body = convert_quote_blocks(content, show_media)
   1403 
   1404 	let cw = get_content_warning(ev.tags)
   1405 	if (cw !== null) {
   1406 		let cwHTML = "Content Warning"
   1407 		if (cw === "") {
   1408 			cwHTML += "."
   1409 		} else {
   1410 			cwHTML += `: "<span>${cw}</span>".`
   1411 		}
   1412 		const open = !!DAMUS.cw_open[ev.id]? "open" : ""
   1413 		return `
   1414 		<details class="cw" id="cw_${ev.id}" ${open}>
   1415 		  <summary>${cwHTML}</summary>
   1416 		  ${body}
   1417 		</details>
   1418 		`
   1419 	}
   1420 
   1421 	return body
   1422 }
   1423 
   1424 function sanitize(content)
   1425 {
   1426 	if (!content)
   1427 		return ""
   1428 	return content.replaceAll("<","&lt;").replaceAll(">","&gt;")
   1429 }
   1430 
   1431 function robohash(pk) {
   1432 	return "https://robohash.org/" + pk
   1433 }
   1434 
   1435 function get_picture(pk, profile) {
   1436 	if (profile.resolved_picture)
   1437 		return profile.resolved_picture
   1438 	profile.resolved_picture = sanitize(profile.picture) || robohash(pk)
   1439 	return profile.resolved_picture
   1440 }
   1441 
   1442 function render_name_plain(pk, profile=DEFAULT_PROFILE)
   1443 {
   1444 	if (profile.sanitized_name)
   1445 		return profile.sanitized_name
   1446 
   1447 	const display_name = profile.display_name || profile.user
   1448 	const username = profile.name || "anon"
   1449 	const name = display_name || username
   1450 
   1451 	profile.sanitized_name = sanitize(name)
   1452 	return profile.sanitized_name
   1453 }
   1454 
   1455 function render_pubkey(pk)
   1456 {
   1457 	return pk.slice(-8)
   1458 }
   1459 
   1460 function render_username(pk, profile)
   1461 {
   1462 	return (profile && profile.name) || render_pubkey(pk)
   1463 }
   1464 
   1465 function render_mentioned_name(pk, profile) {
   1466 	return `<span class="username">@${render_username(pk, profile)}</span>`
   1467 }
   1468 
   1469 function render_name(pk, profile) {
   1470 	return `<div class="username">${render_name_plain(pk, profile)}</div>`
   1471 }
   1472 
   1473 function time_delta(current, previous) {
   1474     var msPerMinute = 60 * 1000;
   1475     var msPerHour = msPerMinute * 60;
   1476     var msPerDay = msPerHour * 24;
   1477     var msPerMonth = msPerDay * 30;
   1478     var msPerYear = msPerDay * 365;
   1479 
   1480     var elapsed = current - previous;
   1481 
   1482     if (elapsed < msPerMinute) {
   1483          return Math.round(elapsed/1000) + ' seconds ago';
   1484     }
   1485 
   1486     else if (elapsed < msPerHour) {
   1487          return Math.round(elapsed/msPerMinute) + ' minutes ago';
   1488     }
   1489 
   1490     else if (elapsed < msPerDay ) {
   1491          return Math.round(elapsed/msPerHour ) + ' hours ago';
   1492     }
   1493 
   1494     else if (elapsed < msPerMonth) {
   1495         return Math.round(elapsed/msPerDay) + ' days ago';
   1496     }
   1497 
   1498     else if (elapsed < msPerYear) {
   1499         return Math.round(elapsed/msPerMonth) + ' months ago';
   1500     }
   1501 
   1502     else {
   1503         return Math.round(elapsed/msPerYear ) + ' years ago';
   1504     }
   1505 }
   1506 
   1507 function toggle_cw(el) {
   1508 	el.classList.toggle("active");
   1509     const isOn = el.classList.contains("active");
   1510 	const input = el.parentElement.querySelector("input.cw");
   1511 	input.classList.toggle("hide", !isOn);
   1512 }