damus.io

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

damus.js (43513B)


      1 
      2 let DSTATE
      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 	DSTATE = 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 		account: "account",//uuidv4(),
    110 		home: "home",//uuidv4(),
    111 		contacts: "contacts",//uuidv4(),
    112 		notifications: "notifications",//uuidv4(),
    113 		dms: "dms",//uuidv4(),
    114 	}
    115 
    116 	model.pool = pool
    117 	model.view_el = document.querySelector("#view")
    118 	redraw_home_view(model)
    119 
    120 	document.addEventListener('visibilitychange', () => {
    121 		update_title(model)
    122 	})
    123 
    124 	pool.on('open', (relay) => {
    125 		//let authors = followers
    126 		// TODO: fetch contact list
    127 		log_debug("relay connected", relay.url)
    128 
    129 		if (!model.done_init) {
    130 			model.loading = false
    131 
    132 			send_initial_filters(ids.account, model.pubkey, relay)
    133 		} else {
    134 			send_home_filters(ids, model, relay)
    135 		}
    136 		//relay.subscribe(comments_id, {kinds: [1,42], limit: 100})
    137 	});
    138 
    139 	pool.on('event', (relay, sub_id, ev) => {
    140 		handle_home_event(ids, model, relay, sub_id, ev)
    141 	})
    142 
    143 	pool.on('eose', async (relay, sub_id) => {
    144 		if (sub_id === ids.home) {
    145 			handle_comments_loaded(ids.profiles, model, relay)
    146 		} else if (sub_id === ids.profiles) {
    147 			handle_profiles_loaded(ids.profiles, model, relay)
    148 		}
    149 	})
    150 
    151 	return pool
    152 }
    153 
    154 function process_reaction_event(model, ev)
    155 {
    156 	if (!is_valid_reaction_content(ev.content))
    157 		return
    158 
    159 	let last = {}
    160 
    161 	for (const tag of ev.tags) {
    162 		if (tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"))
    163 			last[tag[0]] = tag[1]
    164 	}
    165 
    166 	if (last.e) {
    167 		model.reactions_to[last.e] = model.reactions_to[last.e] || new Set()
    168 		model.reactions_to[last.e].add(ev.id)
    169 	}
    170 }
    171 
    172 function process_chatroom_event(model, ev)
    173 {
    174 	try {
    175 		model.chatrooms[ev.id] = JSON.parse(ev.content)
    176 	} catch (err) {
    177 		log_debug("error processing chatroom creation event", ev, err)
    178 	}
    179 }
    180 
    181 function process_json_content(ev)
    182 {
    183 	try {
    184 		ev.json_content = JSON.parse(ev.content)
    185 	} catch(e) {
    186 		log_debug("error parsing json content for", ev)
    187 	}
    188 }
    189 
    190 function process_deletion_event(model, ev)
    191 {
    192 	for (const tag of ev.tags) {
    193 		if (tag.length >= 2 && tag[0] === "e") {
    194 			const evid = tag[1]
    195 
    196 			// we've already recorded this one as a valid deleted event
    197 			// we can just ignore it
    198 			if (model.deleted[evid])
    199 				continue
    200 
    201 			let ds = model.deletions[evid] = (model.deletions[evid] || new Set())
    202 
    203 			// add the deletion event id to the deletion set of this event
    204 			// we will use this to determine if this event is valid later in
    205 			// case we don't have the deleted event yet.
    206 			ds.add(ev.id)
    207 		}
    208 	}
    209 }
    210 
    211 function is_deleted(model, evid)
    212 {
    213 	// we've already know it's deleted
    214 	if (model.deleted[evid])
    215 		return model.deleted[evid]
    216 
    217 	const ev = model.all_events[evid]
    218 	if (!ev)
    219 		return false
    220 
    221 	// all deletion events
    222 	const ds = model.deletions[ev.id]
    223 	if (!ds)
    224 		return false
    225 
    226 	// find valid deletion events
    227 	for (const id of ds.keys()) {
    228 		const d_ev = model.all_events[id]
    229 		if (!d_ev)
    230 			continue
    231 
    232 		// only allow deletes from the user who created it
    233 		if (d_ev.pubkey === ev.pubkey) {
    234 			model.deleted[ev.id] = d_ev
    235 			log_debug("received deletion for", ev)
    236 			// clean up deletion data that we don't need anymore
    237 			delete model.deletions[ev.id]
    238 			return true
    239 		} else {
    240 			log_debug(`User ${d_ev.pubkey} tried to delete ${ev.pubkey}'s event ... what?`)
    241 		}
    242 	}
    243 
    244 	return false
    245 }
    246 
    247 function process_event(model, ev)
    248 {
    249 	ev.refs = determine_event_refs(ev.tags)
    250 	const notified = was_pubkey_notified(model.pubkey, ev)
    251 	ev.notified = notified
    252 
    253 	if (ev.kind === 7)
    254 		process_reaction_event(model, ev)
    255 	else if (ev.kind === 42 && ev.refs && ev.refs.root)
    256 		notice_chatroom(model, ev.refs.root)
    257 	else if (ev.kind === 40)
    258 		process_chatroom_event(model, ev)
    259 	else if (ev.kind === 6)
    260 		process_json_content(ev)
    261 	else if (ev.kind === 5)
    262 		process_deletion_event(model, ev)
    263 
    264 	const last_notified = get_local_state('last_notified_date')
    265 	if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
    266 		set_local_state('last_notified_date', new Date().getTime())
    267 		model.notifications++
    268 		update_title(model)
    269 	}
    270 }
    271 
    272 function was_pubkey_notified(pubkey, ev)
    273 {
    274 	if (!(ev.kind === 1 || ev.kind === 42))
    275 		return false
    276 
    277 	if (ev.pubkey === pubkey)
    278 		return false
    279 
    280 	for (const tag of ev.tags) {
    281 		if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
    282 			return true
    283 	}
    284 
    285 	return false
    286 }
    287 
    288 function should_add_to_home(ev)
    289 {
    290 	return ev.kind === 1 || ev.kind === 42 || ev.kind === 6
    291 }
    292 
    293 let rerender_home_timer
    294 function handle_home_event(ids, model, relay, sub_id, ev) {
    295 	model.all_events[ev.id] = ev
    296 	process_event(model, ev)
    297 
    298 	switch (sub_id) {
    299 	case ids.home:
    300 		if (should_add_to_home(ev))
    301 			insert_event_sorted(model.events, ev)
    302 
    303 		if (model.realtime) {
    304 			if (rerender_home_timer)
    305 				clearTimeout(rerender_home_timer)
    306 			rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500)
    307 		}
    308 		break;
    309 	case ids.account:
    310 		switch (ev.kind) {
    311 		case 3:
    312 			model.loading = false
    313 			process_contact_event(model, ev)
    314 			model.done_init = true
    315 			model.pool.unsubscribe(ids.account, [relay])
    316 			break
    317 		case 0:
    318 			handle_profile_event(model, ev)
    319 			break
    320 		}
    321 	case ids.profiles:
    322 		try {
    323 			model.profile_events[ev.pubkey] = ev
    324 			model.profiles[ev.pubkey] = JSON.parse(ev.content)
    325 		} catch {
    326 			console.log("failed to parse", ev.content)
    327 		}
    328 	}
    329 }
    330 
    331 function handle_profile_event(model, ev) {
    332 	console.log("PROFILE", ev)
    333 }
    334 
    335 function send_initial_filters(account_id, pubkey, relay) {
    336 	const filter = {authors: [pubkey], kinds: [3], limit: 1}
    337 	//console.log("sending initial filter", filter)
    338 	relay.subscribe(account_id, filter)
    339 }
    340 
    341 function send_home_filters(ids, model, relay) {
    342 	const friends = contacts_friend_list(model.contacts)
    343 	friends.push(model.pubkey)
    344 
    345 	const contacts_filter = {kinds: [0], authors: friends}
    346 	const dms_filter = {kinds: [4], limit: 500}
    347 	const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500}
    348 	const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500}
    349 	const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100}
    350 
    351 	let home_filters = [home_filter]
    352 	let notifications_filters = [notifications_filter]
    353 	let contacts_filters = [contacts_filter]
    354 	let dms_filters = [dms_filter, our_dms_filter]
    355 
    356 	let last_of_kind = {}
    357 	if (relay) {
    358 		last_of_kind =
    359 			model.last_event_of_kind[relay] =
    360 			model.last_event_of_kind[relay] || {}
    361 	}
    362 
    363         update_filters_with_since(last_of_kind, home_filters)
    364         update_filters_with_since(last_of_kind, contacts_filters)
    365         update_filters_with_since(last_of_kind, notifications_filters)
    366         update_filters_with_since(last_of_kind, dms_filters)
    367 
    368 	const subto = relay? [relay] : undefined
    369 	model.pool.subscribe(ids.home, home_filters, subto)
    370 	model.pool.subscribe(ids.contacts, contacts_filters, subto)
    371 	model.pool.subscribe(ids.notifications, notifications_filters, subto)
    372 	model.pool.subscribe(ids.dms, dms_filters, subto)
    373 }
    374 
    375 function get_since_time(last_event) {
    376 	if (!last_event) {
    377 		return null
    378 	}
    379 
    380 	return last_event.created_at - 60 * 10
    381 }
    382 
    383 function update_filter_with_since(last_of_kind, filter) {
    384 	const kinds = filter.kinds || []
    385 	let initial = null
    386 	let earliest = kinds.reduce((earliest, kind) => {
    387 		const last = last_of_kind[kind]
    388 		let since = get_since_time(last)
    389 
    390 		if (!earliest) {
    391 			if (since === null)
    392 				return null
    393 
    394 			return since
    395 		}
    396 
    397 		if (since === null)
    398 			return earliest
    399 
    400 		return since < earliest ? since : earliest
    401 
    402 	}, initial)
    403 
    404 	if (earliest)
    405 		filter.since = earliest
    406 }
    407 
    408 function update_filters_with_since(last_of_kind, filters) {
    409 	for (const filter of filters) {
    410 		update_filter_with_since(last_of_kind, filter)
    411 	}
    412 }
    413 
    414 function contacts_friend_list(contacts) {
    415 	return Array.from(contacts.friends)
    416 }
    417 
    418 function process_contact_event(model, ev) {
    419 	load_our_contacts(model.contacts, model.pubkey, ev)
    420 	load_our_relays(model.pubkey, model.pool, ev)
    421 	add_contact_if_friend(model.contacts, ev)
    422 }
    423 
    424 function add_contact_if_friend(contacts, ev) {
    425 	if (!contact_is_friend(contacts, ev.pubkey)) {
    426 		return
    427 	}
    428 
    429 	add_friend_contact(contacts, ev)
    430 }
    431 
    432 function contact_is_friend(contacts, pk) {
    433 	return contacts.friends.has(pk)
    434 }
    435 
    436 function add_friend_contact(contacts, contact) {
    437 	contacts.friends[contact.pubkey] = true
    438 
    439         for (const tag of contact.tags) {
    440             if (tag.count >= 2 && tag[0] == "p") {
    441                 contacts.friend_of_friends.add(tag[1])
    442             }
    443         }
    444 }
    445 
    446 function load_our_relays(our_pubkey, pool, ev) {
    447 	if (ev.pubkey != our_pubkey)
    448 		return
    449 
    450 	let relays
    451 	try {
    452 		relays = JSON.parse(ev.content)
    453 	} catch (e) {
    454 		log_debug("error loading relays", e)
    455 		return
    456 	}
    457 
    458 	for (const relay of Object.keys(relays)) {
    459 		log_debug("adding relay", relay)
    460 		if (!pool.has(relay))
    461 			pool.add(relay)
    462 	}
    463 }
    464 
    465 function log_debug(fmt, ...args) {
    466 	console.log("[debug] " + fmt, ...args)
    467 }
    468 
    469 function load_our_contacts(contacts, our_pubkey, ev) {
    470 	if (ev.pubkey !== our_pubkey)
    471 		return
    472 
    473 	contacts.event = ev
    474 
    475 	for (const tag of ev.tags) {
    476 		if (tag.length > 1 && tag[0] === "p") {
    477 			contacts.friends.add(tag[1])
    478 		}
    479 	}
    480 }
    481 
    482 function handle_profiles_loaded(profiles_id, model, relay) {
    483 	// stop asking for profiles
    484 	model.pool.unsubscribe(profiles_id, relay)
    485 	model.realtime = true
    486 
    487 	redraw_events(model)
    488 }
    489 
    490 function debounce(f, interval) {
    491 	let timer = null;
    492 	let first = true;
    493 
    494 	return (...args) => {
    495 		clearTimeout(timer);
    496 		return new Promise((resolve) => {
    497 			timer = setTimeout(() => resolve(f(...args)), first? 0 : interval);
    498 			first = false
    499 		});
    500 	};
    501 }
    502 
    503 function get_unknown_chatroom_ids(state)
    504 {
    505 	let chatroom_ids = []
    506 	for (const key of Object.keys(state.chatrooms)) {
    507 		const chatroom = state.chatrooms[key]
    508 		if (chatroom.name === undefined)
    509 			chatroom_ids.push(key)
    510 	}
    511 	return chatroom_ids
    512 }
    513 
    514 // load profiles after comment notes are loaded
    515 function handle_comments_loaded(profiles_id, model, relay)
    516 {
    517 	const pubkeys = model.events.reduce((s, ev) => {
    518 		s.add(ev.pubkey)
    519 		return s
    520 	}, new Set())
    521 	const authors = Array.from(pubkeys)
    522 
    523 	// load profiles and noticed chatrooms
    524 	const chatroom_ids = get_unknown_chatroom_ids(model)
    525 	const profile_filter = {kinds: [0], authors: authors}
    526 	const chatroom_filter = {kinds: [40], ids: chatroom_ids}
    527 	const filters = [profile_filter, chatroom_filter]
    528 
    529 	//console.log("subscribe", profiles_id, filter, relay)
    530 	model.pool.subscribe(profiles_id, filters, relay)
    531 }
    532 
    533 function redraw_events(model) {
    534 	//log_debug("rendering home view")
    535 	model.rendered = {}
    536 	model.events_el.innerHTML = render_events(model)
    537 	setup_home_event_handlers(model.events_el)
    538 }
    539 
    540 function setup_home_event_handlers(events_el)
    541 {
    542 	for (const el of events_el.querySelectorAll(".cw"))
    543 		el.addEventListener("toggle", toggle_content_warning.bind(null))
    544 }
    545 
    546 function redraw_home_view(model) {
    547 	model.view_el.innerHTML = render_home_view(model)
    548 	model.events_el = document.querySelector("#events")
    549 	if (model.events.length > 0)
    550 		redraw_events(model)
    551 	else
    552 		model.events_el.innerText = "Loading..."
    553 }
    554 
    555 async function send_post() {
    556 	const input_el = document.querySelector("#post-input")
    557 	const cw_el = document.querySelector("#content-warning-input")
    558 
    559 	const cw = cw_el.value
    560 	const content = input_el.value
    561 	const created_at = Math.floor(new Date().getTime() / 1000)
    562 	const kind = 1
    563 	const tags = cw? [["content-warning", cw]] : []
    564 	const pubkey = await get_pubkey()
    565 	const {pool} = DSTATE
    566 
    567 	let post = { pubkey, tags, content, created_at, kind }
    568 
    569 	post.id = await nostrjs.calculate_id(post)
    570 	post = await sign_event(post)
    571 
    572 	pool.send(["EVENT", post])
    573 
    574 	input_el.value = ""
    575 	cw_el.value = ""
    576 }
    577 
    578 async function sign_event(ev) {
    579 	if (window.nostr && window.nostr.signEvent) {
    580 		const signed = await window.nostr.signEvent(ev)
    581 		if (typeof signed === 'string') {
    582 			ev.sig = signed
    583 			return ev
    584 		}
    585 		return signed
    586 	}
    587 
    588 	const privkey = get_privkey()
    589 	ev.sig = await sign_id(privkey, ev.id)
    590 	return ev
    591 }
    592 
    593 function render_home_view(model) {
    594 	return `
    595 	<div id="newpost">
    596 		<div id="content-warning-input-container">
    597 			<input id="content-warning-input" type="text" placeholder="Content Warning (nsfw, politics, etc)"></input>
    598 		</div>
    599 
    600 		<div class="post-group">
    601 			<textarea placeholder="What's on your mind?" id="post-input"></textarea>
    602 			<button onclick="send_post(this)" id="post-button">Post</button>
    603 		</div>
    604 	</div>
    605 	<div id="events">
    606 	</div>
    607 	`
    608 }
    609 
    610 function render_home_event(model, ev)
    611 {
    612 	let max_depth = 3
    613 	if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) {
    614 		max_depth = null
    615 	}
    616 
    617 	return render_event(model, ev, {max_depth})
    618 }
    619 
    620 function render_events(model) {
    621 	return model.events
    622 		.filter((ev, i) => i < 140)
    623 		.map((ev) => render_home_event(model, ev)).join("\n")
    624 }
    625 
    626 function determine_event_refs_positionally(pubkeys, ids)
    627 {
    628 	if (ids.length === 1)
    629 		return {root: ids[0], reply: ids[0], pubkeys}
    630 	else if (ids.length >= 2)
    631 		return {root: ids[0], reply: ids[1], pubkeys}
    632 
    633 	return {pubkeys}
    634 }
    635 
    636 function determine_event_refs(tags) {
    637 	let positional_ids = []
    638 	let pubkeys = []
    639 	let root
    640 	let reply
    641 	let i = 0
    642 
    643 	for (const tag of tags) {
    644 		if (tag.length >= 4 && tag[0] == "e") {
    645 			if (tag[3] === "root")
    646 				root = tag[1]
    647 			else if (tag[3] === "reply")
    648 				reply = tag[1]
    649 		} else if (tag.length >= 2 && tag[0] == "e") {
    650 			positional_ids.push(tag[1])
    651 		} else if (tag.length >= 2 && tag[0] == "p") {
    652 			pubkeys.push(tag[1])
    653 		}
    654 
    655 		i++
    656 	}
    657 
    658 	if (!root && !reply && positional_ids.length > 0)
    659 		return determine_event_refs_positionally(pubkeys, positional_ids)
    660 
    661 	return {root, reply, pubkeys}
    662 }
    663 
    664 function render_reply_line_top(invisible) {
    665 	const classes = invisible ? "invisible" : ""
    666 	return `<div class="line-top ${classes}"></div>`
    667 }
    668 
    669 function render_reply_line_bot() {
    670 	return `<div class="line-bot"></div>`
    671 }
    672 
    673 function can_reply(ev) {
    674 	return ev.kind === 1 || ev.kind === 42
    675 }
    676 
    677 const DEFAULT_PROFILE = {
    678 	name: "anon",
    679 	display_name: "Anonymous",
    680 }
    681 
    682 function render_thread_collapsed(model, reply_ev, opts)
    683 {
    684 	if (opts.is_composing)
    685 		return ""
    686 	return `<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed clickable">...</div>`
    687 }
    688 
    689 function* yield_etags(tags)
    690 {
    691 	for (const tag of tags) {
    692 		if (tag.length >= 2 && tag[0] === "e")
    693 			yield tag
    694 	}
    695 }
    696 
    697 function expand_thread(id) {
    698 	const ev = DSTATE.all_events[id]
    699 	if (ev) {
    700 		for (const tag of yield_etags(ev.tags))
    701 			DSTATE.expanded.add(tag[1])
    702 	}
    703 	DSTATE.expanded.add(id)
    704 	redraw_events(DSTATE)
    705 }
    706 
    707 function render_replied_events(model, ev, opts)
    708 {
    709 	if (!(ev.refs && ev.refs.reply))
    710 		return ""
    711 
    712 	const reply_ev = model.all_events[ev.refs.reply]
    713 	if (!reply_ev)
    714 		return ""
    715 
    716 	opts.replies = opts.replies == null ? 1 : opts.replies + 1
    717 	if (!(opts.max_depth == null || opts.replies < opts.max_depth))
    718 		return render_thread_collapsed(model, reply_ev, opts)
    719 
    720 	opts.is_reply = true
    721 	return render_event(model, reply_ev, opts)
    722 }
    723 
    724 function render_replying_to_chat(model, ev) {
    725 	const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {}
    726 	const roomname = chatroom.name || ev.refs.root || "??"
    727 	const pks = ev.refs.pubkeys || []
    728 	const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
    729 	const to_users = pks.length === 0 ? "" : ` to ${names}`
    730 
    731 	return `<div class="replying-to small-txt">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
    732 }
    733 
    734 function render_replying_to(model, ev) {
    735 	if (!(ev.refs && ev.refs.reply))
    736 		return ""
    737 
    738 	if (ev.kind === 42)
    739 		return render_replying_to_chat(model, ev)
    740 
    741 	let pubkeys = ev.refs.pubkeys || []
    742 	if (pubkeys.length === 0 && ev.refs.reply) {
    743 		const replying_to = model.all_events[ev.refs.reply]
    744 		if (!replying_to)
    745 			return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>`
    746 
    747 		pubkeys = [replying_to.pubkey]
    748 	}
    749 
    750 	const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
    751 
    752 	return `
    753 	<span class="replying-to small-txt">
    754 		replying to ${names}
    755 	</span>
    756 	`
    757 }
    758 
    759 function render_delete_post(model, ev) {
    760 	if (model.pubkey !== ev.pubkey)
    761 		return ""
    762 
    763 	return `
    764 	<span onclick="delete_post_confirm('${ev.id}')" class="clickable" style="float: right">
    765    766 	</span>
    767 	`
    768 }
    769 
    770 function delete_post_confirm(evid) {
    771 	if (!confirm("Are you sure you want to delete this post?"))
    772 		return
    773 
    774 	const reason = (prompt("Why you are deleting this? Leave empty to not specify. Type CANCEL to cancel.") || "").trim()
    775 
    776 	if (reason.toLowerCase() === "cancel")
    777 		return
    778 
    779 	delete_post(evid, reason)
    780 }
    781 
    782 function render_unknown_event(model, ev) {
    783 	return "Unknown event"
    784 }
    785 
    786 function render_boost(model, ev, opts) {
    787 	//todo validate content
    788 	if (!ev.json_content)
    789 		return render_unknown_event(ev)
    790 	
    791 	const profile = model.profiles[ev.pubkey]
    792 	opts.is_boost_event = true
    793 	return `
    794 	<div class="boost">
    795 	<div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div>
    796 	${render_event(model, ev.json_content, opts)}
    797 	</div>
    798 	`
    799 }
    800 
    801 function shouldnt_render_event(model, ev, opts) {
    802 	return !opts.is_boost_event && !opts.is_composing && !model.expanded.has(ev.id) && model.rendered[ev.id]
    803 }
    804 
    805 function render_deleted_name() {
    806 	return "???"
    807 }
    808 
    809 function render_deleted_pfp() {
    810 	return `<div class="pfp pfp-normal">😵</div>`
    811 }
    812 
    813 function render_comment_body(model, ev, opts) {
    814 	const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev)
    815 	const show_media = !opts.is_composing
    816 
    817 	return `
    818 	<div>
    819 	${render_replying_to(model, ev)}
    820 	${render_delete_post(model, ev)}
    821 	</div>
    822 	<p>
    823 	${format_content(ev, show_media)}
    824 	</p>
    825 	${render_reactions(model, ev)}
    826 	${bar}
    827 	`
    828 }
    829 
    830 function render_deleted_comment_body(ev, deleted) {
    831 	if (deleted.content) {
    832 		const show_media = false
    833 		return `
    834 		<div class="deleted-comment">
    835 			This comment was deleted. Reason:
    836 			<div class="quote">${format_content(deleted, show_media)}</div>
    837 		</div>
    838 		`
    839 	}
    840 
    841 
    842 	return `<div class="deleted-comment">This comment was deleted</div>`
    843 }
    844 
    845 function render_event(model, ev, opts={}) {
    846 	if (ev.kind === 6)
    847 		return render_boost(model, ev, opts)
    848 	if (shouldnt_render_event(model, ev, opts))
    849 		return ""
    850 	delete opts.is_boost_event
    851 	model.rendered[ev.id] = true
    852 	const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE
    853 	const delta = time_delta(new Date().getTime(), ev.created_at*1000)
    854 
    855 	const has_bot_line = opts.is_reply
    856 	const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
    857 
    858 	const deleted = is_deleted(model, ev.id)
    859 	if (deleted && !opts.is_reply)
    860 		return ""
    861 
    862 	const replied_events = render_replied_events(model, ev, opts)
    863 
    864 	return `
    865 	${replied_events}
    866 	<div id="ev${ev.id}" class="comment">
    867 		<div class="info">
    868 			${deleted ? render_deleted_name() : render_name(ev.pubkey, profile)}
    869 			<span class="timestamp">${delta}</span>
    870 		</div>
    871 		<div class="pfpbox">
    872 			${render_reply_line_top(replied_events === "")}
    873 			${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
    874 			${reply_line_bot}
    875 		</div>
    876 		<div class="comment-body">
    877 			${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)}
    878 		</div>
    879 	</div>
    880 	`
    881 }
    882 
    883 function render_pfp(pk, profile, size="normal") {
    884 	const name = render_name_plain(pk, profile)
    885 	return `<img title="${name}" class="pfp pfp-${size}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">`
    886 }
    887 
    888 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])?))?)$/
    889 
    890 const WORD_REGEX=/\w/
    891 function is_emoji(str)
    892 {
    893 	return !WORD_REGEX.test(str) && REACTION_REGEX.test(str)
    894 }
    895 
    896 function is_valid_reaction_content(content)
    897 {
    898 	return content === "+" || content === "" || is_emoji(content)
    899 }
    900 
    901 function get_reaction_emoji(ev) {
    902 	if (ev.content === "+" || ev.content === "")
    903 		return "❤️"
    904 
    905 	return ev.content
    906 }
    907 
    908 function render_reaction_group(model, emoji, reactions, reacting_to) {
    909 	const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk]))
    910 
    911 	let onclick = ""
    912 	const reaction = reactions[model.pubkey]
    913 	if (!reaction) {
    914 		onclick = `onclick="send_reply('${emoji}', '${reacting_to.id}')"`
    915 	} else {
    916 		onclick = `onclick="delete_post('${reaction.id}')"`
    917 	}
    918 
    919 	return `
    920 	<span ${onclick} class="reaction-group clickable">
    921 	  <span class="reaction-emoji">
    922 	  ${emoji}
    923 	  </span>
    924 	  ${pfps.join("\n")}
    925 	</span>
    926 	`
    927 }
    928 
    929 async function delete_post(id, reason)
    930 {
    931 	const ev = DSTATE.all_events[id]
    932 	if (!ev)
    933 		return
    934 
    935 	const pubkey = await get_pubkey()
    936 	let del = await create_deletion_event(pubkey, id, reason)
    937 	console.log("deleting", ev)
    938 	broadcast_event(del)
    939 }
    940 
    941 function render_reaction(model, reaction) {
    942 	const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE
    943 	let emoji = reaction.content[0]
    944 	if (reaction.content === "+" || reaction.content === "")
    945 		emoji = "❤️"
    946 
    947 	return render_pfp(reaction.pubkey, profile, "small")
    948 }
    949 
    950 function render_reactions(model, ev) {
    951 	const reactions_set = model.reactions_to[ev.id]
    952 	if (!reactions_set)
    953 		return ""
    954 
    955 	let reactions = []
    956 	for (const id of reactions_set.keys()) {
    957 		if (is_deleted(model, id))
    958 			continue
    959 		const reaction = model.all_events[id]
    960 		if (!reaction)
    961 			continue
    962 		reactions.push(reaction)
    963 	}
    964 
    965 	let str = ""
    966 	const groups = reactions.reduce((grp, r) => {
    967 		const e = get_reaction_emoji(r)
    968 		grp[e] = grp[e] || {}
    969 		grp[e][r.pubkey] = r
    970 		return grp
    971 	}, {})
    972 
    973 	for (const emoji of Object.keys(groups)) {
    974 		str += render_reaction_group(model, emoji, groups[emoji], ev)
    975 	}
    976 
    977 	return `
    978 	<div class="reactions">
    979 	  ${str}
    980 	</div>
    981 	`
    982 }
    983 
    984 function close_reply() {
    985 	const modal = document.querySelector("#reply-modal")
    986 	modal.style.display = "none";
    987 }
    988 
    989 function gather_reply_tags(pubkey, from) {
    990 	let tags = []
    991 	for (const tag of from.tags) {
    992 		if (tag.length >= 2) {
    993 			if (tag[0] === "e") {
    994 				tags.push(tag)
    995 			} else if (tag[0] === "p" && tag[1] !== pubkey) {
    996 				tags.push(tag)
    997 			}
    998 		}
    999 	}
   1000 	tags.push(["e", from.id, "", "reply"])
   1001 	if (from.pubkey !== pubkey)
   1002 		tags.push(["p", from.pubkey])
   1003 	return tags
   1004 }
   1005 
   1006 async function create_deletion_event(pubkey, target, content="")
   1007 {
   1008 	const created_at = Math.floor(new Date().getTime() / 1000)
   1009 	let kind = 5
   1010 
   1011 	const tags = [["e", target]]
   1012 	let del = { pubkey, tags, content, created_at, kind }
   1013 
   1014 	del.id = await nostrjs.calculate_id(del)
   1015 	del = await sign_event(del)
   1016 	return del
   1017 }
   1018 
   1019 async function create_reply(pubkey, content, from) {
   1020 	const tags = gather_reply_tags(pubkey, from)
   1021 	const created_at = Math.floor(new Date().getTime() / 1000)
   1022 	let kind = from.kind
   1023 
   1024 	// convert emoji replies into reactions
   1025 	if (is_valid_reaction_content(content))
   1026 		kind = 7
   1027 
   1028 	let reply = { pubkey, tags, content, created_at, kind }
   1029 
   1030 	reply.id = await nostrjs.calculate_id(reply)
   1031 	reply = await sign_event(reply)
   1032 	return reply
   1033 }
   1034 
   1035 function get_tag_event(tag)
   1036 {
   1037 	if (tag.length < 2)
   1038 		return null
   1039 
   1040 	if (tag[0] === "e")
   1041 		return DSTATE.all_events[tag[1]]
   1042 
   1043 	if (tag[0] === "p")
   1044 		return DSTATE.profile_events[tag[1]]
   1045 
   1046 	return null
   1047 }
   1048 
   1049 async function broadcast_related_events(ev)
   1050 {
   1051 	ev.tags
   1052 		.reduce((evs, tag) => {
   1053 			// cap it at something sane
   1054 			if (evs.length >= 5)
   1055 				return evs
   1056 			const ev = get_tag_event(tag)
   1057 			if (!ev)
   1058 				return evs
   1059 			insert_event_sorted(evs, ev) // for uniqueness
   1060 			return evs
   1061 		}, [])
   1062 		.forEach((ev, i) => {
   1063 			// so we don't get rate limited
   1064 			setTimeout(() => {
   1065 				log_debug("broadcasting related event", ev)
   1066 				broadcast_event(ev)
   1067 			}, (i+1)*1200)
   1068 		})
   1069 }
   1070 
   1071 function broadcast_event(ev) {
   1072 	DSTATE.pool.send(["EVENT", ev])
   1073 }
   1074 
   1075 async function send_reply(content, replying_to)
   1076 {
   1077 	const ev = DSTATE.all_events[replying_to]
   1078 	if (!ev)
   1079 		return
   1080 
   1081 	const pubkey = await get_pubkey()
   1082 	let reply = await create_reply(pubkey, content, ev)
   1083 
   1084 	broadcast_event(reply)
   1085 	broadcast_related_events(reply)
   1086 }
   1087 
   1088 async function do_send_reply() {
   1089 	const modal = document.querySelector("#reply-modal")
   1090 	const replying_to = modal.querySelector("#replying-to")
   1091 
   1092 	const evid = replying_to.dataset.evid
   1093 	const reply_content_el = document.querySelector("#reply-content")
   1094 	const content = reply_content_el.value
   1095 
   1096 	await send_reply(content, evid)
   1097 
   1098 	reply_content_el.value = ""
   1099 
   1100 	close_reply()
   1101 }
   1102 
   1103 function bech32_decode(pubkey) {
   1104 	const decoded = bech32.decode(pubkey)
   1105 	const bytes = fromWords(decoded.words)
   1106 	return nostrjs.hex_encode(bytes)
   1107 }
   1108 
   1109 function get_local_state(key) {
   1110 	if (DSTATE[key] != null)
   1111 		return DSTATE[key]
   1112 
   1113 	return localStorage.getItem(key)
   1114 }
   1115 
   1116 function set_local_state(key, val) {
   1117 	DSTATE[key] = val
   1118 	localStorage.setItem(key, val)
   1119 }
   1120 
   1121 async function get_pubkey() {
   1122 	let pubkey = get_local_state('pubkey')
   1123 
   1124 	if (pubkey)
   1125 		return pubkey
   1126 
   1127 	if (window.nostr && window.nostr.getPublicKey) {
   1128 		const pubkey = await window.nostr.getPublicKey()
   1129 		console.log("got %s pubkey from nos2x", pubkey)
   1130 		return pubkey
   1131 	}
   1132 
   1133 	pubkey = prompt("Enter pubkey (hex or npub)")
   1134 
   1135 	if (!pubkey)
   1136 		throw new Error("Need pubkey to continue")
   1137 
   1138 	if (pubkey[0] === "n")
   1139 		pubkey = bech32_decode(pubkey)
   1140 
   1141 	set_local_state('pubkey', pubkey)
   1142 	return pubkey
   1143 }
   1144 
   1145 function get_privkey() {
   1146 	let privkey = get_local_state('privkey')
   1147 
   1148 	if (privkey)
   1149 		return privkey
   1150 
   1151 	if (!privkey)
   1152 		privkey = prompt("Enter private key")
   1153 
   1154 	if (!privkey)
   1155 		throw new Error("can't get privkey")
   1156 
   1157 	if (privkey[0] === "n") {
   1158 		privkey = bech32_decode(privkey)
   1159 	}
   1160 
   1161 	set_local_state('privkey', privkey)
   1162 
   1163 	return privkey
   1164 }
   1165 
   1166 async function sign_id(privkey, id)
   1167 {
   1168 	//const digest = nostrjs.hex_decode(id)
   1169 	const sig = await nobleSecp256k1.schnorr.sign(id, privkey)
   1170 	return nostrjs.hex_encode(sig)
   1171 }
   1172 
   1173 function reply_to(evid) {
   1174 	const modal = document.querySelector("#reply-modal")
   1175 	const replying = modal.style.display === "none";
   1176 	const replying_to = modal.querySelector("#replying-to")
   1177 
   1178 	replying_to.dataset.evid = evid
   1179 	const ev = DSTATE.all_events[evid]
   1180 	replying_to.innerHTML = render_event(DSTATE, ev, {is_composing: true, nobar: true, max_depth: 1})
   1181 
   1182 	modal.style.display = replying? "block" : "none";
   1183 }
   1184 
   1185 function render_action_bar(ev) {
   1186 	return `
   1187 	<div class="action-bar">
   1188 		<a href="javascript:reply_to('${ev.id}')">reply</a>
   1189 	</div>
   1190 	`
   1191 }
   1192 
   1193 const IMG_REGEX = /(png|jpeg|jpg|gif|webp)$/i
   1194 function is_img_url(path) {
   1195 	return IMG_REGEX.test(path)
   1196 }
   1197 
   1198 const VID_REGEX = /(webm|mp4)$/i
   1199 function is_video_url(path) {
   1200 	return VID_REGEX.test(path)
   1201 }
   1202 
   1203 const URL_REGEX = /(https?:\/\/[^\s\):]+)/g;
   1204 function linkify(text, show_media) {
   1205 	return text.replace(URL_REGEX, function(url) {
   1206 		const parsed = new URL(url)
   1207 		if (show_media && is_img_url(parsed.pathname))
   1208 			return `<img class="inline-img" src="${url}"/>`;
   1209 		else if (show_media && is_video_url(parsed.pathname))
   1210 			return `
   1211 			<video controls class="inline-img" />
   1212 			  <source src="${url}">
   1213 			</video>
   1214 			`;
   1215 		else
   1216 			return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`;
   1217 	})
   1218 }
   1219 
   1220 function convert_quote_blocks(content, show_media)
   1221 {
   1222 	const split = content.split("\n")
   1223 	let blockin = false
   1224 	return split.reduce((str, line) => {
   1225 		if (line !== "" && line[0] === '>') {
   1226 			if (!blockin) {
   1227 				str += "<span class='quote'>"
   1228 				blockin = true
   1229 			}
   1230 			str += linkify(sanitize(line.slice(1)), show_media)
   1231 		} else {
   1232 			if (blockin) {
   1233 				blockin = false
   1234 				str += "</span>"
   1235 			}
   1236 			str += linkify(sanitize(line), show_media)
   1237 		}
   1238 		return str + "<br/>"
   1239 	}, "")
   1240 }
   1241 
   1242 function get_content_warning(tags)
   1243 {
   1244 	for (const tag of tags) {
   1245 		if (tag.length >= 1 && tag[0] === "content-warning")
   1246 			return tag[1] || ""
   1247 	}
   1248 
   1249 	return null
   1250 }
   1251 
   1252 function toggle_content_warning(e)
   1253 {
   1254 	const el = e.target
   1255 	const id = el.id.split("_")[1]
   1256 	const ev = DSTATE.all_events[id]
   1257 
   1258 	if (!ev) {
   1259 		log_debug("could not find content-warning event", id)
   1260 		return
   1261 	}
   1262 
   1263 	DSTATE.cw_open[id] = el.open
   1264 }
   1265 
   1266 function format_content(ev, show_media)
   1267 {
   1268 	if (ev.kind === 7) {
   1269 		if (ev.content === "" || ev.content === "+")
   1270 			return "❤️"
   1271 		return sanitize(ev.content.trim())
   1272 	}
   1273 
   1274 	const content = ev.content.trim()
   1275 	const body = convert_quote_blocks(content, show_media)
   1276 
   1277 	let cw = get_content_warning(ev.tags)
   1278 	if (cw !== null) {
   1279 		cw = cw === ""? "Content Warning" : `Content Warning: ${cw}`
   1280 		const open = !!DSTATE.cw_open[ev.id]? "open" : ""
   1281 		return `
   1282 		<details class="cw" id="cw_${ev.id}" ${open}>
   1283 		  <summary>${cw}</summary>
   1284 		  ${body}
   1285 		</details>
   1286 		`
   1287 	}
   1288 
   1289 	return body
   1290 }
   1291 
   1292 function sanitize(content)
   1293 {
   1294 	if (!content)
   1295 		return ""
   1296 	return content.replaceAll("<","&lt;").replaceAll(">","&gt;")
   1297 }
   1298 
   1299 function robohash(pk) {
   1300 	return "https://robohash.org/" + pk
   1301 }
   1302 
   1303 function get_picture(pk, profile) {
   1304 	if (profile.resolved_picture)
   1305 		return profile.resolved_picture
   1306 	profile.resolved_picture = sanitize(profile.picture) || robohash(pk)
   1307 	return profile.resolved_picture
   1308 }
   1309 
   1310 function render_name_plain(pk, profile=DEFAULT_PROFILE)
   1311 {
   1312 	if (profile.sanitized_name)
   1313 		return profile.sanitized_name
   1314 
   1315 	const display_name = profile.display_name || profile.user
   1316 	const username = profile.name || "anon"
   1317 	const name = display_name || username
   1318 
   1319 	profile.sanitized_name = sanitize(name)
   1320 	return profile.sanitized_name
   1321 }
   1322 
   1323 function render_pubkey(pk)
   1324 {
   1325 	return pk.slice(-8)
   1326 }
   1327 
   1328 function render_username(pk, profile)
   1329 {
   1330 	return (profile && profile.name) || render_pubkey(pk)
   1331 }
   1332 
   1333 function render_mentioned_name(pk, profile) {
   1334 	return `<span class="username">@${render_username(pk, profile)}</span>`
   1335 }
   1336 
   1337 function render_name(pk, profile) {
   1338 	return `<div class="username">${render_name_plain(pk, profile)}</div>`
   1339 }
   1340 
   1341 function time_delta(current, previous) {
   1342     var msPerMinute = 60 * 1000;
   1343     var msPerHour = msPerMinute * 60;
   1344     var msPerDay = msPerHour * 24;
   1345     var msPerMonth = msPerDay * 30;
   1346     var msPerYear = msPerDay * 365;
   1347 
   1348     var elapsed = current - previous;
   1349 
   1350     if (elapsed < msPerMinute) {
   1351          return Math.round(elapsed/1000) + ' seconds ago';
   1352     }
   1353 
   1354     else if (elapsed < msPerHour) {
   1355          return Math.round(elapsed/msPerMinute) + ' minutes ago';
   1356     }
   1357 
   1358     else if (elapsed < msPerDay ) {
   1359          return Math.round(elapsed/msPerHour ) + ' hours ago';
   1360     }
   1361 
   1362     else if (elapsed < msPerMonth) {
   1363         return Math.round(elapsed/msPerDay) + ' days ago';
   1364     }
   1365 
   1366     else if (elapsed < msPerYear) {
   1367         return Math.round(elapsed/msPerMonth) + ' months ago';
   1368     }
   1369 
   1370     else {
   1371         return Math.round(elapsed/msPerYear ) + ' years ago';
   1372     }
   1373 }