yosup/webv2/damus.js
2022-10-27 18:22:46 -07:00

655 lines
15 KiB
JavaScript

let DSTATE
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
function insert_event_sorted(evs, new_ev) {
for (let i = 0; i < evs.length; i++) {
const ev = evs[i]
if (new_ev.id === ev.id) {
return false
}
if (new_ev.created_at > ev.created_at) {
evs.splice(i, 0, new_ev)
return true
}
}
evs.push(new_ev)
return true
}
function init_contacts() {
return {
event: null,
friends: new Set(),
friend_of_friends: new Set(),
}
}
function init_home_model() {
return {
done_init: false,
loading: true,
rendered: {},
all_events: {},
events: [],
profiles: {},
last_event_of_kind: {},
contacts: init_contacts()
}
}
async function damus_web_init()
{
const {RelayPool} = nostrjs
const pool = RelayPool(["wss://relay.damus.io"])
const now = (new Date().getTime()) / 1000
const model = init_home_model()
DSTATE = model
const ids = {
comments: "comments",//uuidv4(),
profiles: "profiles",//uuidv4(),
account: "account",//uuidv4(),
home: "home",//uuidv4(),
contacts: "contacts",//uuidv4(),
notifications: "notifications",//uuidv4(),
dms: "dms",//uuidv4(),
}
model.pubkey = get_pubkey()
if (!model.pubkey)
return
model.pool = pool
model.el = document.querySelector("#posts")
pool.on('open', (relay) => {
//let authors = followers
// TODO: fetch contact list
log_debug("relay connected", relay.url)
if (!model.done_init) {
model.loading = false
send_initial_filters(ids.account, model.pubkey, relay)
} else {
send_home_filters(ids, model, relay)
}
//relay.subscribe(comments_id, {kinds: [1,42], limit: 100})
});
pool.on('event', (relay, sub_id, ev) => {
handle_home_event(ids, model, relay, sub_id, ev)
})
pool.on('eose', async (relay, sub_id) => {
if (sub_id === ids.home) {
handle_comments_loaded(ids.profiles, model, relay)
} else if (sub_id === ids.profiles) {
handle_profiles_loaded(ids.profiles, model, relay)
}
})
return pool
}
function process_event(ev)
{
ev.refs = determine_event_refs(ev.tags)
}
let rerender_home_timer
function handle_home_event(ids, model, relay, sub_id, ev) {
model.all_events[ev.id] = ev
switch (sub_id) {
case ids.home:
if (ev.content !== "") {
process_event(ev)
insert_event_sorted(model.events, ev)
}
if (model.realtime) {
if (rerender_home_timer)
clearTimeout(rerender_home_timer)
rerender_home_timer = setTimeout(render_home_view.bind(null, model), 200)
}
break;
case ids.account:
switch (ev.kind) {
case 3:
model.loading = false
process_contact_event(model, ev)
model.done_init = true
model.pool.unsubscribe(ids.account, [relay])
break
case 0:
handle_profile_event(model, ev)
break
}
case ids.profiles:
try {
model.profiles[ev.pubkey] = JSON.parse(ev.content)
} catch {
console.log("failed to parse", ev.content)
}
}
}
function handle_profile_event(model, ev) {
console.log("PROFILE", ev)
}
function send_initial_filters(account_id, pubkey, relay) {
const filter = {authors: [pubkey], kinds: [3], limit: 1}
relay.subscribe(account_id, filter)
}
function send_home_filters(ids, model, relay) {
const friends = contacts_friend_list(model.contacts)
friends.push(model.pubkey)
const contacts_filter = {kinds: [0], authors: friends}
const dms_filter = {kinds: [4], limit: 500}
const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500}
const home_filter = {kinds: [1,42,6,7], authors: friends, limit: 500}
const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100}
let home_filters = [home_filter]
let notifications_filters = [notifications_filter]
let contacts_filters = [contacts_filter]
let dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = {}
if (relay) {
last_of_kind =
model.last_event_of_kind[relay] =
model.last_event_of_kind[relay] || {}
}
update_filters_with_since(last_of_kind, home_filters)
update_filters_with_since(last_of_kind, contacts_filters)
update_filters_with_since(last_of_kind, notifications_filters)
update_filters_with_since(last_of_kind, dms_filters)
const subto = relay? [relay] : undefined
model.pool.subscribe(ids.home, home_filters, subto)
model.pool.subscribe(ids.contacts, contacts_filters, subto)
model.pool.subscribe(ids.notifications, notifications_filters, subto)
model.pool.subscribe(ids.dms, dms_filters, subto)
}
function get_since_time(last_event) {
if (!last_event) {
return null
}
return last_event.created_at - 60 * 10
}
function update_filter_with_since(last_of_kind, filter) {
const kinds = filter.kinds || []
let initial = null
let earliest = kinds.reduce((earliest, kind) => {
const last = last_of_kind[kind]
let since = get_since_time(last)
if (!earliest) {
if (since === null)
return null
return since
}
if (since === null)
return earliest
return since < earliest ? since : earliest
}, initial)
if (earliest)
filter.since = earliest
}
function update_filters_with_since(last_of_kind, filters) {
for (const filter of filters) {
update_filter_with_since(last_of_kind, filter)
}
}
function contacts_friend_list(contacts) {
return Array.from(contacts.friends)
}
function process_contact_event(model, ev) {
load_our_contacts(model.contacts, model.pubkey, ev)
load_our_relays(model.pubkey, model.pool, ev)
add_contact_if_friend(model.contacts, ev)
}
function add_contact_if_friend(contacts, ev) {
if (!contact_is_friend(contacts, ev.pubkey)) {
return
}
add_friend_contact(contacts, ev)
}
function contact_is_friend(contacts, pk) {
return contacts.friends.has(pk)
}
function add_friend_contact(contacts, contact) {
contacts.friends[contact.pubkey] = true
for (const tag of contact.tags) {
if (tag.count >= 2 && tag[0] == "p") {
contacts.friend_of_friends.add(tag[1])
}
}
}
function load_our_relays(our_pubkey, pool, ev) {
if (ev.pubkey != our_pubkey)
return
let relays
try {
relays = JSON.parse(ev.content)
} catch (e) {
log_debug("error loading relays", e)
return
}
for (const relay of Object.keys(relays)) {
log_debug("adding relay", relay)
if (!pool.has(relay))
pool.add(relay)
}
}
function log_debug(fmt, ...args) {
console.log("[debug] " + fmt, ...args)
}
function load_our_contacts(contacts, our_pubkey, ev) {
if (ev.pubkey !== our_pubkey)
return
contacts.event = ev
for (const tag of ev.tags) {
if (tag.length > 1 && tag[0] === "p") {
contacts.friends.add(tag[1])
}
}
}
function handle_profiles_loaded(profiles_id, model, relay) {
// stop asking for profiles
model.pool.unsubscribe(profiles_id, relay)
model.realtime = true
render_home_view(model)
}
function debounce(f, interval) {
let timer = null;
let first = true;
return (...args) => {
clearTimeout(timer);
return new Promise((resolve) => {
timer = setTimeout(() => resolve(f(...args)), first? 0 : interval);
first = false
});
};
}
// load profiles after comment notes are loaded
function handle_comments_loaded(profiles_id, model, relay)
{
const pubkeys = model.events.reduce((s, ev) => {
s.add(ev.pubkey)
return s
}, new Set())
const authors = Array.from(pubkeys)
// load profiles
const filter = {kinds: [0], authors: authors}
console.log("subscribe", profiles_id, filter, relay)
model.pool.subscribe(profiles_id, filter, relay)
}
function render_home_view(model) {
log_debug("rendering home view")
model.rendered = {}
model.el.innerHTML = render_events(model)
}
function render_events(model) {
return model.events.map((ev) => render_event(model, ev)).join("\n")
}
function determine_event_refs_positionally(ids)
{
if (ids.length === 1)
return {reply: ids[0]}
else if (ids.length === 2)
return {root: ids[0], reply: ids[1]}
return {}
}
function determine_event_refs(tags) {
let positional_ids = []
let root
let reply
let i = 0
for (const tag of tags) {
if (tag.length >= 4 && tag[0] == "e") {
if (tag[3] === "root")
root = tag[1]
else if (tag[3] === "reply")
reply = tag[1]
// we found both a root and a reply, we're done
if (root !== undefined && reply !== undefined)
break
} else if (tag.length >= 2 && tag[0] == "e") {
positional_ids.push(tag[1])
}
i++
}
if (!root && !reply && positional_ids.length > 0)
return determine_event_refs_positionally(positional_ids)
return {root, reply}
}
function render_reply_line_top() {
return `<div class="line-top"></div>`
}
function render_reply_line_bot() {
return `<div class="line-bot"></div>`
}
function render_event(model, ev, opts={}) {
if (!opts.is_composing && model.rendered[ev.id])
return ""
model.rendered[ev.id] = true
const profile = model.profiles[ev.pubkey] || {
name: "anon",
display_name: "Anonymous",
}
const delta = time_delta(new Date().getTime(), ev.created_at*1000)
const pk = ev.pubkey
const bar = opts.nobar? "" : render_action_bar(ev)
let replying_to = ""
let reply_line_top = ""
const has_bot_line = opts.is_reply
if (ev.refs && ev.refs.reply) {
const reply_ev = model.all_events[ev.refs.reply]
if (reply_ev) {
opts.replies = opts.replies == null ? 1 : opts.replies + 1
opts.is_reply = true
replying_to = render_event(model, reply_ev, opts)
reply_line_top = render_reply_line_top()
}
}
const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
return `
${replying_to}
<div id="ev${ev.id}" class="comment">
<div class="info">
${render_name(ev.pubkey, profile)}
<span>${delta}</span>
</div>
<div class="pfpbox">
${reply_line_top}
<img class="pfp" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">
${reply_line_bot}
</div>
<p>
${format_content(ev.content)}
${bar}
</p>
</div>
`
}
function close_reply() {
const modal = document.querySelector("#reply-modal")
modal.style.display = "none";
}
function gather_reply_tags(pubkey, from) {
let tags = []
for (const tag of from.tags) {
if (tag.length >= 2) {
if (tag[0] === "e") {
tags.push(tag)
} else if (tag[0] === "p" && tag[1] !== pubkey) {
tags.push(tag)
}
}
}
tags.push(["e", from.id, "", "reply"])
if (from.pubkey !== pubkey)
tags.push(["p", from.pubkey])
return tags
}
async function create_reply(privkey, pubkey, content, from) {
const tags = gather_reply_tags(pubkey, from)
const created_at = Math.floor(new Date().getTime() / 1000)
const kind = from.kind
let reply = { pubkey, tags, content, created_at, kind }
reply.id = await nostrjs.calculate_id(reply)
reply.sig = await sign_id(privkey, reply.id)
return reply
}
async function send_reply() {
const modal = document.querySelector("#reply-modal")
const replying_to = modal.querySelector("#replying-to")
const evid = replying_to.dataset.evid
const ev = DSTATE.all_events[evid]
const { pool } = DSTATE
const content = document.querySelector("#reply-content").value
const pubkey = get_pubkey()
const privkey = get_privkey()
let reply = await create_reply(privkey, pubkey, content, ev)
console.log(nostrjs.event_commitment(reply), reply)
pool.send(["EVENT", reply])
close_reply()
}
function bech32_decode(pubkey) {
const decoded = bech32.decode(pubkey)
const bytes = fromWords(decoded.words)
return nostrjs.hex_encode(bytes)
}
function get_local_state(key) {
if (DSTATE[key] != null)
return DSTATE[key]
return localStorage.getItem(key)
}
function set_local_state(key, val) {
DSTATE[key] = val
localStorage.setItem(key, val)
}
function get_pubkey() {
let pubkey = get_local_state('pubkey')
if (pubkey)
return pubkey
pubkey = prompt("Enter pubkey (hex or npub)")
if (!pubkey)
throw new Error("Need pubkey to continue")
if (pubkey[0] === "n")
pubkey = bech32_decode(pubkey)
set_local_state('pubkey', pubkey)
return pubkey
}
function get_privkey() {
let privkey = get_local_state('privkey')
if (privkey)
return privkey
if (!privkey)
privkey = prompt("Enter private key")
if (!privkey)
throw new Error("can't get privkey")
if (privkey[0] === "n") {
privkey = bech32_decode(privkey)
}
set_local_state('privkey', privkey)
return privkey
}
async function sign_id(privkey, id)
{
//const digest = nostrjs.hex_decode(id)
const sig = await nobleSecp256k1.schnorr.sign(id, privkey)
return nostrjs.hex_encode(sig)
}
function reply_to(evid) {
const modal = document.querySelector("#reply-modal")
const replying = modal.style.display === "none";
const replying_to = modal.querySelector("#replying-to")
replying_to.dataset.evid = evid
const ev = DSTATE.all_events[evid]
replying_to.innerHTML = render_event(DSTATE, ev, {is_composing: true, nobar: true})
modal.style.display = replying? "block" : "none";
}
function render_action_bar(ev) {
return `
<a href="javascript:reply_to('${ev.id}')">reply</a>
`
}
function convert_quote_blocks(content)
{
const split = content.split("\n")
let blockin = false
return split.reduce((str, line) => {
if (line !== "" && line[0] === '>') {
if (!blockin) {
str += "<span class='quote'>"
blockin = true
}
str += sanitize(line.slice(1))
} else {
if (blockin) {
blockin = false
str += "</span>"
}
str += sanitize(line)
}
return str + "<br/>"
}, "")
}
function format_content(content)
{
return convert_quote_blocks(content)
}
function sanitize(content)
{
if (!content)
return ""
return content.replaceAll("<","&lt;").replaceAll(">","&gt;")
}
function robohash(pk) {
return "https://robohash.org/" + pk
}
function get_picture(pk, profile)
{
return sanitize(profile.picture) || robohash(pk)
}
function render_name(pk, profile={})
{
const display_name = profile.display_name || profile.user
const username = profile.name || "anon"
const name = display_name || username
return `<div class="username">${sanitize(name)}</div>`
}
function time_delta(current, previous) {
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = current - previous;
if (elapsed < msPerMinute) {
return Math.round(elapsed/1000) + ' seconds ago';
}
else if (elapsed < msPerHour) {
return Math.round(elapsed/msPerMinute) + ' minutes ago';
}
else if (elapsed < msPerDay ) {
return Math.round(elapsed/msPerHour ) + ' hours ago';
}
else if (elapsed < msPerMonth) {
return Math.round(elapsed/msPerDay) + ' days ago';
}
else if (elapsed < msPerYear) {
return Math.round(elapsed/msPerMonth) + ' months ago';
}
else {
return Math.round(elapsed/msPerYear ) + ' years ago';
}
}