forked from nostr/nostrweb
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
8.5 KiB
TypeScript
296 lines
8.5 KiB
TypeScript
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 {sub24hFeed, subNote, subProfile} from './subscriptions'
|
|
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
|
|
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
|
|
import {closeSettingsView, config, toggleSettingsView} from './settings';
|
|
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';
|
|
|
|
// 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);
|
|
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;
|
|
// }
|
|
};
|
|
|
|
document.body.addEventListener('click', (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
const a = target?.closest('a');
|
|
if (a) {
|
|
// dont intercept command-click
|
|
if (event.metaKey) {
|
|
return;
|
|
}
|
|
handleLink(a, event);
|
|
return;
|
|
}
|
|
const button = target?.closest('button');
|
|
if (button) {
|
|
handleButton(button);
|
|
}
|
|
});
|