Merge remote-tracking branch 'thomas/main'

web: removed non-working event buttons
Deleted js/index.js
Removed .DS_Store
web: Refactor UI code
web: retab & avoid race condition on main js entry
web: reconstructed project
This commit is contained in:
William Casarin 2022-11-12 11:13:03 -08:00
commit c3b4ac0d5b
23 changed files with 471 additions and 528 deletions

169
web/js/bech32.js Normal file
View file

@ -0,0 +1,169 @@
var ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
var ALPHABET_MAP = {};
for (var z = 0; z < ALPHABET.length; z++) {
var x = ALPHABET.charAt(z);
ALPHABET_MAP[x] = z;
}
function polymodStep(pre) {
var b = pre >> 25;
return (((pre & 0x1ffffff) << 5) ^
(-((b >> 0) & 1) & 0x3b6a57b2) ^
(-((b >> 1) & 1) & 0x26508e6d) ^
(-((b >> 2) & 1) & 0x1ea119fa) ^
(-((b >> 3) & 1) & 0x3d4233dd) ^
(-((b >> 4) & 1) & 0x2a1462b3));
}
function prefixChk(prefix) {
var chk = 1;
for (var i = 0; i < prefix.length; ++i) {
var c = prefix.charCodeAt(i);
if (c < 33 || c > 126)
return 'Invalid prefix (' + prefix + ')';
chk = polymodStep(chk) ^ (c >> 5);
}
chk = polymodStep(chk);
for (var i = 0; i < prefix.length; ++i) {
var v = prefix.charCodeAt(i);
chk = polymodStep(chk) ^ (v & 0x1f);
}
return chk;
}
function convertbits(data, inBits, outBits, pad) {
var value = 0;
var bits = 0;
var maxV = (1 << outBits) - 1;
var result = [];
for (var i = 0; i < data.length; ++i) {
value = (value << inBits) | data[i];
bits += inBits;
while (bits >= outBits) {
bits -= outBits;
result.push((value >> bits) & maxV);
}
}
if (pad) {
if (bits > 0) {
result.push((value << (outBits - bits)) & maxV);
}
}
else {
if (bits >= inBits)
return 'Excess padding';
if ((value << (outBits - bits)) & maxV)
return 'Non-zero padding';
}
return result;
}
function toWords(bytes) {
return convertbits(bytes, 8, 5, true);
}
function fromWordsUnsafe(words) {
var res = convertbits(words, 5, 8, false);
if (Array.isArray(res))
return res;
}
function fromWords(words) {
var res = convertbits(words, 5, 8, false);
if (Array.isArray(res))
return res;
throw new Error(res);
}
function getLibraryFromEncoding(encoding) {
var ENCODING_CONST;
if (encoding === 'bech32') {
ENCODING_CONST = 1;
}
else {
ENCODING_CONST = 0x2bc830a3;
}
function encode(prefix, words, LIMIT) {
LIMIT = LIMIT || 90;
if (prefix.length + 7 + words.length > LIMIT)
throw new TypeError('Exceeds length limit');
prefix = prefix.toLowerCase();
// determine chk mod
var chk = prefixChk(prefix);
if (typeof chk === 'string')
throw new Error(chk);
var result = prefix + '1';
for (var i = 0; i < words.length; ++i) {
var x = words[i];
if (x >> 5 !== 0)
throw new Error('Non 5-bit word');
chk = polymodStep(chk) ^ x;
result += ALPHABET.charAt(x);
}
for (var i = 0; i < 6; ++i) {
chk = polymodStep(chk);
}
chk ^= ENCODING_CONST;
for (var i = 0; i < 6; ++i) {
var v = (chk >> ((5 - i) * 5)) & 0x1f;
result += ALPHABET.charAt(v);
}
return result;
}
function __decode(str, LIMIT) {
LIMIT = LIMIT || 90;
if (str.length < 8)
return str + ' too short';
if (str.length > LIMIT)
return 'Exceeds length limit';
// don't allow mixed case
var lowered = str.toLowerCase();
var uppered = str.toUpperCase();
if (str !== lowered && str !== uppered)
return 'Mixed-case string ' + str;
str = lowered;
var split = str.lastIndexOf('1');
if (split === -1)
return 'No separator character for ' + str;
if (split === 0)
return 'Missing prefix for ' + str;
var prefix = str.slice(0, split);
var wordChars = str.slice(split + 1);
if (wordChars.length < 6)
return 'Data too short';
var chk = prefixChk(prefix);
if (typeof chk === 'string')
return chk;
var words = [];
for (var i = 0; i < wordChars.length; ++i) {
var c = wordChars.charAt(i);
var v = ALPHABET_MAP[c];
if (v === undefined)
return 'Unknown character ' + c;
chk = polymodStep(chk) ^ v;
// not in the checksum?
if (i + 6 >= wordChars.length)
continue;
words.push(v);
}
if (chk !== ENCODING_CONST)
return 'Invalid checksum for ' + str;
return { prefix: prefix, words: words };
}
function decodeUnsafe(str, LIMIT) {
var res = __decode(str, LIMIT);
if (typeof res === 'object')
return res;
}
function decode(str, LIMIT) {
var res = __decode(str, LIMIT);
if (typeof res === 'object')
return res;
throw new Error(res);
}
return {
decodeUnsafe: decodeUnsafe,
decode: decode,
encode: encode,
toWords: toWords,
fromWordsUnsafe: fromWordsUnsafe,
fromWords: fromWords
};
}
const bech32 = getLibraryFromEncoding('bech32');
const bech32m = getLibraryFromEncoding('bech32m');

1170
web/js/damus.js Normal file

File diff suppressed because one or more lines are too long

1203
web/js/noble-secp256k1.js Normal file

File diff suppressed because it is too large Load diff

366
web/js/nostr.js Normal file
View file

@ -0,0 +1,366 @@
const nostrjs = (function nostrlib() {
const WS = typeof WebSocket !== 'undefined' ? WebSocket : require('ws')
function RelayPool(relays, opts)
{
if (!(this instanceof RelayPool))
return new RelayPool(relays)
this.onfn = {}
this.relays = []
for (const relay of relays) {
this.add(relay)
}
return this
}
RelayPool.prototype.close = function relayPoolClose() {
for (const relay of this.relays) {
relay.close()
}
}
RelayPool.prototype.on = function relayPoolOn(method, fn) {
for (const relay of this.relays) {
this.onfn[method] = fn
relay.onfn[method] = fn.bind(null, relay)
}
}
RelayPool.prototype.has = function relayPoolHas(relayUrl) {
for (const relay of this.relays) {
if (relay.url === relayUrl)
return true
}
return false
}
RelayPool.prototype.setupHandlers = function relayPoolSetupHandlers()
{
// setup its message handlers with the ones we have already
const keys = Object.keys(this.onfn)
for (const handler of keys) {
for (const relay of this.relays) {
relay.onfn[handler] = this.onfn[handler].bind(null, relay)
}
}
}
RelayPool.prototype.remove = function relayPoolRemove(url) {
let i = 0
for (const relay of this.relays) {
if (relay.url === url) {
relay.ws && relay.ws.close()
this.relays = this.replays.splice(i, 1)
return true
}
i += 1
}
return false
}
RelayPool.prototype.subscribe = function relayPoolSubscribe(sub_id, filters, relay_ids) {
const relays = relay_ids ? this.find_relays(relay_ids) : this.relays
for (const relay of relays) {
relay.subscribe(sub_id, filters)
}
}
RelayPool.prototype.unsubscribe = function relayPoolUnsubscibe(sub_id, relay_ids) {
const relays = relay_ids ? this.find_relays(relay_ids) : this.relays
for (const relay of relays) {
relay.unsubscribe(sub_id)
}
}
RelayPool.prototype.send = function relayPoolSend(payload, relay_ids) {
const relays = relay_ids ? this.find_relays(relay_ids) : this.relays
for (const relay of relays) {
relay.send(payload)
}
}
RelayPool.prototype.add = function relayPoolAdd(relay) {
if (relay instanceof Relay) {
if (this.has(relay.url))
return false
this.relays.push(relay)
this.setupHandlers()
return true
}
if (this.has(relay))
return false
const r = Relay(relay, this.opts)
this.relays.push(r)
this.setupHandlers()
return true
}
RelayPool.prototype.find_relays = function relayPoolFindRelays(relay_ids) {
if (relay_ids instanceof Relay)
return [relay_ids]
if (relay_ids.length === 0)
return []
if (!relay_ids[0])
throw new Error("what!?")
if (relay_ids[0] instanceof Relay)
return relay_ids
return this.relays.reduce((acc, relay) => {
if (relay_ids.some((rid) => relay.url === rid))
acc.push(relay)
return acc
}, [])
}
Relay.prototype.wait_connected = async function relay_wait_connected(data) {
let retry = 1000
while (true) {
if (!this.ws || this.ws.readyState !== 1) {
await sleep(retry)
retry *= 1.5
}
else {
return
}
}
}
function Relay(relay, opts={})
{
if (!(this instanceof Relay))
return new Relay(relay, opts)
this.url = relay
this.opts = opts
if (opts.reconnect == null)
opts.reconnect = true
const me = this
me.onfn = {}
try {
init_websocket(me)
} catch (e) {
console.log(e)
}
return this
}
function init_websocket(me) {
let ws
try {
ws = me.ws = new WS(me.url);
} catch(e) {
return null
}
return new Promise((resolve, reject) => {
let resolved = false
ws.onmessage = (m) => { handle_nostr_message(me, m) }
ws.onclose = () => {
if (me.onfn.close)
me.onfn.close()
if (me.reconnecting)
return reject(new Error("close during reconnect"))
if (!me.manualClose && me.opts.reconnect)
reconnect(me)
}
ws.onerror = () => {
if (me.onfn.error)
me.onfn.error()
if (me.reconnecting)
return reject(new Error("error during reconnect"))
if (me.opts.reconnect)
reconnect(me)
}
ws.onopen = () => {
if (me.onfn.open)
me.onfn.open()
else
console.log("no onopen???", me)
if (resolved) return
resolved = true
resolve(me)
}
});
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function reconnect(me)
{
const reconnecting = true
let n = 100
try {
me.reconnecting = true
await init_websocket(me)
me.reconnecting = false
} catch {
//console.error(`error thrown during reconnect... trying again in ${n} ms`)
await sleep(n)
n *= 1.5
}
}
Relay.prototype.on = function relayOn(method, fn) {
this.onfn[method] = fn
}
Relay.prototype.close = function relayClose() {
if (this.ws) {
this.manualClose = true
this.ws.close()
}
}
Relay.prototype.subscribe = function relay_subscribe(sub_id, filters) {
if (Array.isArray(filters))
this.send(["REQ", sub_id, ...filters])
else
this.send(["REQ", sub_id, filters])
}
Relay.prototype.unsubscribe = function relay_unsubscribe(sub_id) {
this.send(["CLOSE", sub_id])
}
Relay.prototype.send = async function relay_send(data) {
await this.wait_connected()
this.ws.send(JSON.stringify(data))
}
function handle_nostr_message(relay, msg)
{
let data
try {
data = JSON.parse(msg.data)
} catch (e) {
console.error("handle_nostr_message", msg, e)
return
}
if (data.length >= 2) {
switch (data[0]) {
case "EVENT":
if (data.length < 3)
return
return relay.onfn.event && relay.onfn.event(data[1], data[2])
case "EOSE":
return relay.onfn.eose && relay.onfn.eose(data[1])
case "NOTICE":
return relay.onfn.notice && relay.onfn.notice(...data.slice(1))
}
}
}
async function sha256(message) {
if (crypto.subtle) {
const buffer = await crypto.subtle.digest('SHA-256', message);
return new Uint8Array(buffer);
} else if (require) {
const { createHash } = require('crypto');
const hash = createHash('sha256');
[message].forEach((m) => hash.update(m));
return Uint8Array.from(hash.digest());
} else {
throw new Error("The environment doesn't have sha256 function");
}
}
async function calculate_id(ev) {
const commit = event_commitment(ev)
const buf = new TextEncoder().encode(commit);
return hex_encode(await sha256(buf))
}
function event_commitment(ev) {
const {pubkey,created_at,kind,tags,content} = ev
return JSON.stringify([0, pubkey, created_at, kind, tags, content])
}
function hex_char(val) {
if (val < 10)
return String.fromCharCode(48 + val)
if (val < 16)
return String.fromCharCode(97 + val - 10)
}
function hex_encode(buf) {
let str = ""
for (let i = 0; i < buf.length; i++) {
const c = buf[i]
str += hex_char(c >> 4)
str += hex_char(c & 0xF)
}
return str
}
function char_to_hex(cstr) {
const c = cstr.charCodeAt(0)
// c >= 0 && c <= 9
if (c >= 48 && c <= 57) {
return c - 48;
}
// c >= a && c <= f
if (c >= 97 && c <= 102) {
return c - 97 + 10;
}
// c >= A && c <= F
if (c >= 65 && c <= 70) {
return c - 65 + 10;
}
return -1;
}
function hex_decode(str, buflen)
{
let bufsize = buflen || 33
let c1, c2
let i = 0
let j = 0
let buf = new Uint8Array(bufsize)
let slen = str.length
while (slen > 1) {
if (-1==(c1 = char_to_hex(str[j])) || -1==(c2 = char_to_hex(str[j+1])))
return null;
if (!bufsize)
return null;
j += 2
slen -= 2
buf[i++] = (c1 << 4) | c2
bufsize--;
}
return buf
}
return {
RelayPool,
calculate_id,
event_commitment,
hex_encode,
hex_decode,
}
})()
if (typeof module !== 'undefined' && module.exports)
module.exports = nostrjs

343
web/js/ui/render.js Normal file
View file

@ -0,0 +1,343 @@
// This file contains all methods related to rendering UI elements. Rendering
// is done by simple string manipulations & templates. If you need to write
// loops simply write it in code and return strings.
function render_home_view(model) {
return `
<div id="newpost">
<div><!-- empty to accomodate profile pic --></div>
<div>
<textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea>
<div class="post-tools">
<input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/>
<button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon">
<i class="fa-solid fa-triangle-exclamation"></i>
</button>
<button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button>
</div>
</div>
</div>
<div id="events"></div>
`
}
function render_home_event(model, ev)
{
let max_depth = 3
if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) {
max_depth = null
}
return render_event(model, ev, {max_depth})
}
function render_events(model) {
return model.events
.filter((ev, i) => i < 140)
.map((ev) => render_home_event(model, ev)).join("\n")
}
function render_reply_line_top(has_top_line) {
const classes = has_top_line ? "" : "invisible"
return `<div class="line-top ${classes}"></div>`
}
function render_reply_line_bot() {
return `<div class="line-bot"></div>`
}
function render_thread_collapsed(model, reply_ev, opts)
{
if (opts.is_composing)
return ""
return `<div onclick="expand_thread('${reply_ev.id}')" class="thread-collapsed">
<div class="thread-summary">
More messages in thread available. Click to expand.
</div>
</div>`
}
function render_replied_events(model, ev, opts)
{
if (!(ev.refs && ev.refs.reply))
return ""
const reply_ev = model.all_events[ev.refs.reply]
if (!reply_ev)
return ""
opts.replies = opts.replies == null ? 1 : opts.replies + 1
if (!(opts.max_depth == null || opts.replies < opts.max_depth))
return render_thread_collapsed(model, reply_ev, opts)
opts.is_reply = true
return render_event(model, reply_ev, opts)
}
function render_replying_to_chat(model, ev) {
const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {}
const roomname = chatroom.name || ev.refs.root || "??"
const pks = ev.refs.pubkeys || []
const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
const to_users = pks.length === 0 ? "" : ` to ${names}`
return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
}
function render_replying_to(model, ev) {
if (!(ev.refs && ev.refs.reply))
return ""
if (ev.kind === 42)
return render_replying_to_chat(model, ev)
let pubkeys = ev.refs.pubkeys || []
if (pubkeys.length === 0 && ev.refs.reply) {
const replying_to = model.all_events[ev.refs.reply]
if (!replying_to)
return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>`
pubkeys = [replying_to.pubkey]
}
const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
return `
<span class="replying-to small-txt">
replying to ${names}
</span>
`
}
function render_unknown_event(model, ev) {
return "Unknown event"
}
function render_boost(model, ev, opts) {
//todo validate content
if (!ev.json_content)
return render_unknown_event(ev)
//const profile = model.profiles[ev.pubkey]
opts.is_boost_event = true
opts.boosted = {
pubkey: ev.pubkey,
profile: model.profiles[ev.pubkey]
}
return render_event(model, ev.json_content, opts)
//return `
//<div class="boost">
//<div class="boost-text">Reposted by ${render_name_plain(ev.pubkey, profile)}</div>
//${render_event(model, ev.json_content, opts)}
//</div>
//`
}
function render_comment_body(model, ev, opts) {
const can_delete = model.pubkey === ev.pubkey;
const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev, can_delete)
const show_media = !opts.is_composing
return `
<div>
${render_replying_to(model, ev)}
${render_boosted_by(model, ev, opts)}
</div>
<p>
${format_content(ev, show_media)}
</p>
${render_reactions(model, ev)}
${bar}
`
}
function render_boosted_by(model, ev, opts) {
const b = opts.boosted
if (!b) {
return ""
}
// TODO encapsulate username as link/button!
return `
<div class="boosted-by">Shared by
<span class="username" data-pubkey="${b.pubkey}">${render_name_plain(b.pubkey, b.profile)}</span>
</div>
`
}
function render_deleted_comment_body(ev, deleted) {
if (deleted.content) {
const show_media = false
return `
<div class="deleted-comment">
This comment was deleted. Reason:
<div class="quote">${format_content(deleted, show_media)}</div>
</div>
`
}
return `<div class="deleted-comment">This comment was deleted</div>`
}
function render_event(model, ev, opts={}) {
if (ev.kind === 6)
return render_boost(model, ev, opts)
if (shouldnt_render_event(model, ev, opts))
return ""
delete opts.is_boost_event
model.rendered[ev.id] = true
const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE
const delta = time_delta(new Date().getTime(), ev.created_at*1000)
const has_bot_line = opts.is_reply
const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
const deleted = is_deleted(model, ev.id)
if (deleted && !opts.is_reply)
return ""
const replied_events = render_replied_events(model, ev, opts)
let name = "???"
if (!deleted) {
name = render_name_plain(ev.pubkey, profile)
}
const has_top_line = replied_events !== ""
const border_bottom = has_bot_line ? "" : "bottom-border";
return `
${replied_events}
<div id="ev${ev.id}" class="event ${border_bottom}">
<div class="userpic">
${render_reply_line_top(has_top_line)}
${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
${reply_line_bot}
</div>
<div class="event-content">
<div class="info">
<span class="username" data-pubkey="${ev.pubkey}" data-name="${name}">
${name}
</span>
<span class="timestamp">${delta}</span>
</div>
<div class="comment">
${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)}
</div>
</div>
</div>
`
}
function render_pfp(pk, profile, size="normal") {
const name = render_name_plain(pk, profile)
return `<img class="pfp" title="${name}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">`
}
function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) {
const reaction = reactions[our_pubkey]
if (!reaction) {
return `onclick="send_reply('${emoji}', '${reacting_to}')"`
} else {
return `onclick="delete_post('${reaction.id}')"`
}
}
function render_reaction_group(model, emoji, reactions, reacting_to) {
const pfps = Object.keys(reactions).map((pk) => render_reaction(model, reactions[pk]))
let onclick = render_react_onclick(model.pubkey, reacting_to.id, emoji, reactions)
return `
<span ${onclick} class="reaction-group clickable">
<span class="reaction-emoji">
${emoji}
</span>
${pfps.join("\n")}
</span>
`
}
function render_reaction(model, reaction) {
const profile = model.profiles[reaction.pubkey] || DEFAULT_PROFILE
let emoji = reaction.content[0]
if (reaction.content === "+" || reaction.content === "")
emoji = "❤️"
return render_pfp(reaction.pubkey, profile, "small")
}
function render_action_bar(ev, can_delete) {
let delete_html = ""
if (can_delete)
delete_html = `<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"><i class="fa fa-fw fa-trash"></i></a>`
const groups = get_reactions(DAMUS, ev.id)
const like = "❤️"
const likes = groups[like] || {}
const react_onclick = render_react_onclick(DAMUS.pubkey, ev.id, like, likes)
return `
<div class="action-bar">
<button class="icon" title="Reply" onclick="reply_to('${ev.id}')"><i class="fa fa-fw fa-comment"></i></a>
<button class="icon react heart" ${react_onclick} title="Like"><i class="fa fa-fw fa-heart"></i></a>
<!--<button class="icon" title="Share" onclick=""><i class="fa fa-fw fa-link"></i></a>-->
${delete_html}
<!--<button class="icon" title="View raw Nostr event." onclick=""><i class="fa-solid fa-fw fa-code"></i></a>-->
</div>
`
}
function render_reactions(model, ev) {
const groups = get_reactions(model, ev.id)
let str = ""
for (const emoji of Object.keys(groups)) {
str += render_reaction_group(model, emoji, groups[emoji], ev)
}
return `
<div class="reactions">
${str}
</div>
`
}
// Utility Methods
function render_name_plain(pk, profile=DEFAULT_PROFILE)
{
if (profile.sanitized_name)
return profile.sanitized_name
const display_name = profile.display_name || profile.user
const username = profile.name || "anon"
const name = display_name || username
profile.sanitized_name = sanitize(name)
return profile.sanitized_name
}
function render_pubkey(pk)
{
return pk.slice(-8)
}
function render_username(pk, profile)
{
return (profile && profile.name) || render_pubkey(pk)
}
function render_mentioned_name(pk, profile) {
return `<span class="username">@${render_username(pk, profile)}</span>`
}
function render_name(pk, profile) {
return `<div class="username">${render_name_plain(pk, profile)}</div>`
}
function render_deleted_name() {
return "???"
}
function render_deleted_pfp() {
return `<div class="pfp pfp-normal">😵</div>`
}

13
web/js/ui/util.js Normal file
View file

@ -0,0 +1,13 @@
// This file contains utility functions related to UI manipulation. Some code
// may be specific to areas of the UI and others are more utility based. As
// this file grows specific UI area code should be migrated to its own file.
// toggle_cw changes the active stage of the Content Warning for a post. It is
// relative to the element that is pressed.
function toggle_cw(el) {
el.classList.toggle("active");
const isOn = el.classList.contains("active");
const input = el.parentElement.querySelector("input.cw");
input.classList.toggle("hide", !isOn);
}