|
|
|
import {Event, nip19} from 'nostr-tools';
|
|
|
|
import {zeroLeadingBitsCount} from './utils/crypto';
|
|
|
|
import {elem} from './utils/dom';
|
|
|
|
import {bounce} from './utils/time';
|
|
|
|
import {isWssUrl} from './utils/url';
|
|
|
|
import {closeSettingsView, config, toggleSettingsView} from './settings';
|
|
|
|
import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts, subContactList} from './subscriptions'
|
|
|
|
import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
|
|
|
|
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
|
|
|
|
import {handleReaction, handleUpvote} from './reactions';
|
|
|
|
import {closePublishView, openWriteInput, togglePublishView} from './write';
|
|
|
|
import {handleMetadata, renderProfile} from './profiles';
|
|
|
|
import {followContact, getContactUpdateMessage, getContacts, getOwnContacts, refreshFollowing, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
|
|
|
|
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
|
|
|
|
import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
|
|
|
|
|
pool: add relay.nostr.ch
this is another relay, so we have two to play with. this one is running
on the same machine where live nostrweb is hosted at.
at the moment, relay is running nostr-rs-relay v0.7.2:
$ curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
{
"id": "wss://relay.nostr.ch/",
"name": "a nostr relay",
"description": "just another nostr relay",
"supported_nips": [
1,
2,
9,
11,
12,
15,
16,
20,
22,
26
],
"software": "https://git.sr.ht/~gheartsfield/nostr-rs-relay",
"version": "0.7.2"
}
1 year ago
|
|
|
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
|
|
|
|
|
|
|
type EventRelayMap = {
|
|
|
|
[eventId: string]: string[];
|
|
|
|
};
|
|
|
|
const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2]
|
|
|
|
|
|
|
|
const renderNote = (
|
|
|
|
evt: EventWithNip19,
|
|
|
|
i: number,
|
|
|
|
sortedFeeds: EventWithNip19[],
|
|
|
|
) => {
|
|
|
|
if (getViewElem(evt.id)) { // note already in view
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const article = createTextNote(evt, eventRelayMap[evt.id][0]);
|
|
|
|
if (i === 0) {
|
|
|
|
getViewContent().append(article);
|
|
|
|
} else {
|
|
|
|
getViewElem(sortedFeeds[i - 1].id).before(article);
|
|
|
|
}
|
|
|
|
setViewElem(evt.id, article);
|
|
|
|
};
|
|
|
|
|
|
|
|
const renderContact = (pubkey: string) => {
|
|
|
|
if (getViewElem(`contact-${pubkey}`)) { // contact already in view
|
|
|
|
updateFollowBtn(pubkey);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const contact = createContact(pubkey);
|
|
|
|
if (contact) {
|
|
|
|
getViewContent().append(contact);
|
|
|
|
setViewElem(`contact-${pubkey}`, contact);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const hasEnoughPOW = (
|
|
|
|
[tag, , commitment]: string[],
|
|
|
|
eventId: string
|
|
|
|
) => {
|
|
|
|
return tag === 'nonce' && Number(commitment) >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty;
|
|
|
|
};
|
|
|
|
|
|
|
|
const renderFeed = bounce(() => {
|
|
|
|
const view = getViewOptions();
|
|
|
|
switch (view.type) {
|
|
|
|
case 'note':
|
|
|
|
textNoteList
|
|
|
|
.concat(replyList) // search id in notes and replies
|
|
|
|
.filter(note => note.id === view.id)
|
|
|
|
.forEach(renderNote);
|
|
|
|
break;
|
|
|
|
case 'profile':
|
|
|
|
[
|
|
|
|
...textNoteList // get notes
|
|
|
|
.filter(note => note.pubkey === view.id),
|
|
|
|
...replyList.filter(reply => reply.pubkey === view.id) // and replies
|
|
|
|
.map(reply => textNoteList.find(note => note.id === reply.replyTo)) // and the replied to notes
|
|
|
|
.filter(isEvent)
|
|
|
|
]
|
|
|
|
.sort(sortByCreatedAt)
|
|
|
|
.reverse()
|
|
|
|
.forEach(renderNote);
|
|
|
|
|
|
|
|
renderProfile(view.id);
|
|
|
|
refreshFollowing(view.id);
|
|
|
|
break;
|
|
|
|
case 'home':
|
|
|
|
const ids = view.id ? getContacts(view.id) : getOwnContacts();
|
|
|
|
[
|
|
|
|
...textNoteList
|
|
|
|
.filter(note => ids.includes(note.pubkey)),
|
|
|
|
...replyList // search id in notes and replies
|
|
|
|
.filter(reply => ids.includes(reply.pubkey))
|
|
|
|
.map(reply => textNoteList.find(note => note.id === reply.replyTo))
|
|
|
|
.filter(isEvent),
|
|
|
|
]
|
|
|
|
.sort(sortByCreatedAt)
|
|
|
|
.reverse()
|
|
|
|
.forEach(renderNote);
|
|
|
|
break;
|
|
|
|
case 'feed':
|
|
|
|
const now = Math.floor(Date.now() * 0.001);
|
|
|
|
textNoteList
|
|
|
|
.filter(note => {
|
|
|
|
// dont render notes from the future
|
|
|
|
if (note.created_at > now) return false;
|
|
|
|
// if difficulty filter is configured dont render notes with too little pow
|
|
|
|
return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))
|
|
|
|
})
|
|
|
|
.sort(sortByCreatedAt)
|
|
|
|
.reverse()
|
|
|
|
.forEach(renderNote);
|
|
|
|
break;
|
|
|
|
case 'contacts':
|
|
|
|
getContacts(view.id)
|
|
|
|
.forEach(renderContact);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
|
|
|
|
|
|
|
|
const renderReply = (evt: EventWithNip19AndReplyTo) => {
|
|
|
|
const parent = getViewElem(evt.replyTo);
|
|
|
|
if (!parent || getViewElem(evt.id)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let replyContainer = parent.querySelector('.mbox-replies');
|
|
|
|
if (!replyContainer) {
|
|
|
|
replyContainer = elem('div', {className: 'mbox-replies'});
|
|
|
|
parent.append(replyContainer);
|
|
|
|
parent.classList.add('mbox-has-replies');
|
|
|
|
}
|
|
|
|
const reply = createTextNote(evt, eventRelayMap[evt.id][0]);
|
|
|
|
replyContainer.append(reply);
|
|
|
|
setViewElem(evt.id, reply);
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleReply = (evt: EventWithNip19, relay: string) => {
|
|
|
|
if (
|
|
|
|
getViewElem(evt.id) // already rendered probably received from another relay
|
|
|
|
|| evt.tags.some(isMention) // ignore mentions for now
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const replyTo = getReplyTo(evt);
|
|
|
|
if (!replyTo) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const evtWithReplyTo = {replyTo, ...evt};
|
|
|
|
replyList.push(evtWithReplyTo);
|
|
|
|
renderReply(evtWithReplyTo);
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleTextNote = (evt: Event, relay: string) => {
|
|
|
|
if (evt.content.startsWith('vmess://') && !evt.content.includes(' ')) {
|
|
|
|
console.info('drop VMESS encrypted message');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (eventRelayMap[evt.id]) {
|
|
|
|
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem?
|
|
|
|
} else {
|
|
|
|
eventRelayMap[evt.id] = [relay];
|
|
|
|
const evtWithNip19 = {
|
|
|
|
nip19: {
|
|
|
|
note: nip19.noteEncode(evt.id),
|
|
|
|
npub: nip19.npubEncode(evt.pubkey),
|
|
|
|
},
|
|
|
|
...evt,
|
|
|
|
};
|
|
|
|
if (evt.tags.some(hasEventTag)) {
|
|
|
|
handleReply(evtWithNip19, relay);
|
|
|
|
} else {
|
|
|
|
textNoteList.push(evtWithNip19);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!getViewElem(evt.id)) {
|
|
|
|
renderFeed();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const rerenderFeed = () => {
|
|
|
|
clearView();
|
|
|
|
renderFeed();
|
|
|
|
};
|
|
|
|
config.rerenderFeed = rerenderFeed;
|
|
|
|
|
|
|
|
const handleContactList = (evt: Event, relay: string) => {
|
|
|
|
// TODO: if newer and view.type === 'home' rerenderFeed()
|
|
|
|
setContactList(evt);
|
|
|
|
const view = getViewOptions();
|
|
|
|
if (getViewElem(evt.id)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
view.type === 'contacts'
|
|
|
|
&& [view.id, config.pubkey].includes(evt.pubkey) // render if contact-list is from current users or current view
|
|
|
|
) {
|
|
|
|
renderFeed();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (view.type === 'profile' && view.id === evt.pubkey) {
|
|
|
|
// use find instead of sort?
|
|
|
|
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
|
|
|
|
const closestNote = getViewElem(closestTextNotes[0].id);
|
|
|
|
if (!closestNote) {
|
|
|
|
// no close note, try later
|
|
|
|
setTimeout(() => handleContactList(evt, relay), 1500);
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
const [addedContacts, removedContacts] = updateContactList(evt);
|
|
|
|
const content = getContactUpdateMessage(addedContacts, removedContacts);
|
|
|
|
if (!content.length) {
|
|
|
|
// P same as before, maybe only evt.content or 'a' tags changed?
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const art = renderUpdateContact({...evt, content}, relay);
|
|
|
|
closestNote.after(art);
|
|
|
|
setViewElem(evt.id, art);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleRecommendServer = (evt: Event, relay: string) => {
|
|
|
|
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const art = renderRecommendServer(evt, relay);
|
|
|
|
if (textNoteList.length < 2) {
|
|
|
|
getViewContent().append(art);
|
|
|
|
} else {
|
|
|
|
const closestTextNotes = textNoteList
|
|
|
|
// TODO: prob change to hasEnoughPOW
|
|
|
|
.filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty))
|
|
|
|
// use find instead of sort?
|
|
|
|
.sort(sortEventCreatedAt(evt.created_at));
|
|
|
|
getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
|
|
|
|
}
|
|
|
|
setViewElem(evt.id, art);
|
|
|
|
};
|
|
|
|
|
|
|
|
const onEventDetails = (evt: Event, relay: string) => {
|
|
|
|
if (getViewElem(evt.id)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const article = renderEventDetails(evt, relay);
|
|
|
|
getViewContent().append(article);
|
|
|
|
setViewElem(evt.id, article);
|
|
|
|
};
|
|
|
|
|
|
|
|
const onEvent = (evt: Event, relay: string) => {
|
|
|
|
switch (evt.kind) {
|
|
|
|
case 0:
|
|
|
|
handleMetadata(evt, relay);
|
|
|
|
break;
|
|
|
|
case 1:
|
|
|
|
handleTextNote(evt, relay);
|
|
|
|
break;
|
|
|
|
case 2:
|
|
|
|
handleRecommendServer(evt, relay);
|
|
|
|
break;
|
|
|
|
case 3:
|
|
|
|
handleContactList(evt, relay);
|
|
|
|
break;
|
|
|
|
case 7:
|
|
|
|
handleReaction(evt, relay);
|
|
|
|
default:
|
|
|
|
// console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/)
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// subscribe and change view
|
|
|
|
const route = (path: string) => {
|
|
|
|
if (path === '/') {
|
|
|
|
const contactList = getOwnContacts();
|
|
|
|
if (contactList.length) {
|
|
|
|
subPubkeys(contactList, onEvent);
|
|
|
|
view('/', {type: 'home'});
|
|
|
|
} else {
|
|
|
|
subGlobalFeed(onEvent);
|
|
|
|
view('/feed', {type: 'feed'});
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (path === '/feed') {
|
|
|
|
subGlobalFeed(onEvent);
|
|
|
|
view('/feed', {type: 'feed'});
|
|
|
|
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
|
|
|
|
const {type, data} = nip19.decode(path.slice(1));
|
|
|
|
if (typeof data !== 'string') {
|
|
|
|
console.warn('nip19 ProfilePointer, EventPointer and AddressPointer are not yet supported');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
switch(type) {
|
|
|
|
case 'note':
|
|
|
|
subNote(data, onEvent);
|
|
|
|
view(path, {type: 'note', id: data});
|
|
|
|
break;
|
|
|
|
case 'npub':
|
|
|
|
subProfile(data, onEvent);
|
|
|
|
view(path, {type: 'profile', id: data});
|
|
|
|
updateFollowBtn(data);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
console.warn(`type ${type} not yet supported`);
|
|
|
|
}
|
|
|
|
renderFeed();
|
|
|
|
} else if (path.length === 73 && path.match(/^\/contacts\/npub[0-9a-z]+$/)) {
|
|
|
|
const contactNpub = path.slice(10);
|
|
|
|
const {type: contactType, data: contactPubkey} = nip19.decode(contactNpub);
|
|
|
|
if (contactType === 'npub') {
|
|
|
|
subContactList(contactPubkey, onEvent);
|
|
|
|
view(path, {type: 'contacts', id: contactPubkey});
|
|
|
|
}
|
|
|
|
} else if (path.length === 73 && path.match(/^\/timeline\/npub[0-9a-z]+$/)) {
|
|
|
|
const timelineNpub = path.slice(10);
|
|
|
|
const {type: timelineType, data: timelinePubkey} = nip19.decode(timelineNpub);
|
|
|
|
if (timelineType === 'npub') {
|
|
|
|
const timelinePubkeys = getContacts(timelinePubkey);
|
|
|
|
subPubkeys(timelinePubkeys, onEvent);
|
|
|
|
view(path, {type: 'home', id: timelinePubkey});
|
|
|
|
}
|
|
|
|
} else if (path.length === 65) {
|
|
|
|
const eventID = path.slice(1);
|
|
|
|
subEventID(eventID, onEventDetails);
|
|
|
|
view(path, {type: 'event', id: eventID});
|
|
|
|
} else {
|
|
|
|
console.warn('no support for ', path);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// onload
|
|
|
|
route(location.pathname);
|
|
|
|
subOwnContacts(onEvent); // subscribe after route as routing unsubscribes current subs
|
|
|
|
|
|
|
|
// only push a new entry if there is no history onload
|
|
|
|
if (!history.length) {
|
|
|
|
history.pushState({}, '', location.pathname);
|
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener('popstate', (event) => {
|
|
|
|
route(location.pathname);
|
|
|
|
});
|
|
|
|
|
|
|
|
const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
|
|
|
|
const href = a.getAttribute('href');
|
|
|
|
if (typeof href !== 'string') {
|
|
|
|
console.warn('expected anchor to have href attribute', a);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
closeSettingsView();
|
|
|
|
closePublishView();
|
|
|
|
if (href === location.pathname) {
|
|
|
|
e.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
href === '/'
|
|
|
|
|| href.startsWith('/feed')
|
|
|
|
|| href.startsWith('/note')
|
|
|
|
|| href.startsWith('/npub')
|
|
|
|
|| href.startsWith('/contacts/npub')
|
|
|
|
|| href.startsWith('/timeline/npub')
|
|
|
|
|| (href.startsWith('/') && href.length === 65)
|
|
|
|
) {
|
|
|
|
route(href);
|
|
|
|
history.pushState({}, '', href);
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleButton = (button: HTMLButtonElement) => {
|
|
|
|
switch(button.name) {
|
|
|
|
case 'settings':
|
|
|
|
toggleSettingsView();
|
|
|
|
return;
|
|
|
|
case 'new-note':
|
|
|
|
togglePublishView();
|
|
|
|
return;
|
|
|
|
case 'back':
|
|
|
|
closePublishView();
|
|
|
|
return;
|
|
|
|
case 'import':
|
|
|
|
resetContactList(config.pubkey);
|
|
|
|
rerenderFeed();
|
|
|
|
subOwnContacts(onEvent);
|
|
|
|
subGlobalFeed(onEvent);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const id = button.dataset.id || (button.closest('[data-id]') as HTMLElement)?.dataset.id;
|
|
|
|
if (id) {
|
|
|
|
switch(button.name) {
|
|
|
|
case 'reply':
|
|
|
|
openWriteInput(button, id);
|
|
|
|
return;
|
|
|
|
case 'star':
|
|
|
|
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
|
|
|
|
note && handleUpvote(note);
|
|
|
|
return;
|
|
|
|
case 'follow':
|
|
|
|
followContact(id);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// const container = e.target.closest('[data-append]');
|
|
|
|
// if (container) {
|
|
|
|
// container.append(...parseTextContent(container.dataset.append));
|
|
|
|
// delete container.dataset.append;
|
|
|
|
// return;
|
|
|
|
// }
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleContentClick = (content: HTMLElement) => {
|
|
|
|
const card = content.closest('article[data-id]') as HTMLElement;
|
|
|
|
if (
|
|
|
|
!card
|
|
|
|
|| !card.dataset.id
|
|
|
|
|| !card.dataset.kind
|
|
|
|
|| getSelection()?.toString() // do not navigate if user selects text
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const {kind, id} = card.dataset;
|
|
|
|
const href = `/${kind === '1' ? nip19.noteEncode(id) : id}`;
|
|
|
|
route(href);
|
|
|
|
history.pushState({}, '', href);
|
|
|
|
};
|
|
|
|
|
|
|
|
document.body.addEventListener('click', (event: MouseEvent) => {
|
|
|
|
// dont intercept command or shift-click
|
|
|
|
if (event.metaKey || event.shiftKey) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
const a = target?.closest('a');
|
|
|
|
if (a) {
|
|
|
|
handleLink(a, event);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const button = target?.closest('button');
|
|
|
|
if (button) {
|
|
|
|
handleButton(button);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const card = target?.closest('.mbox-body');
|
|
|
|
if (card) {
|
|
|
|
handleContentClick(card as HTMLElement);
|
|
|
|
}
|
|
|
|
});
|