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.
444 lines
13 KiB
TypeScript
444 lines
13 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 {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';
|
|
|
|
// 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);
|
|
}
|
|
});
|