|
|
|
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 {sub24hFeed, subNote, subProfile} from './subscriptions'
|
|
|
|
import {getReplyTo, hasEventTag, 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 {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
|
|
|
|
import {createTextNote, renderRecommendServer} 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"
}
2 years 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 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':
|
|
|
|
const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
|
|
|
|
[
|
|
|
|
...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); // render in-reply-to
|
|
|
|
|
|
|
|
renderProfile(view.id);
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}, 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) { // root article has not been rendered
|
|
|
|
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) {
|
|
|
|
console.warn('expected to find reply-to-event-id', evt);
|
|
|
|
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: just push?
|
|
|
|
} 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();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
config.rerenderFeed = () => {
|
|
|
|
clearView();
|
|
|
|
renderFeed();
|
|
|
|
};
|
|
|
|
|
|
|
|
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))
|
|
|
|
.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 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 === '/') {
|
|
|
|
sub24hFeed(onEvent);
|
|
|
|
view('/', {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});
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
console.warn(`type ${type} not yet supported`);
|
|
|
|
}
|
|
|
|
renderFeed();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// onload
|
|
|
|
route(location.pathname);
|
|
|
|
|
|
|
|
// 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('/note')
|
|
|
|
|| href.startsWith('/npub')
|
|
|
|
) {
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id;
|
|
|
|
if (id) {
|
|
|
|
switch(button.name) {
|
|
|
|
case 'reply':
|
|
|
|
openWriteInput(button, id);
|
|
|
|
break;
|
|
|
|
case 'star':
|
|
|
|
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
|
|
|
|
note && handleUpvote(note);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// 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
|
|
|
|
|| getSelection()?.toString() // do not navigate if user selects text
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const href = `/${nip19.noteEncode(card.dataset.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 content = target?.closest('.mbox-content');
|
|
|
|
if (content) {
|
|
|
|
handleContentClick(content as HTMLElement);
|
|
|
|
}
|
|
|
|
});
|