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); } });