import {Event, nip19, signEvent} from 'nostr-tools'; import {elem} from './utils/dom'; import {dateTime} from './utils/time'; import {isNotNonceTag, isPTag} from './events'; import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view'; import {powEvent} from './system'; import {config} from './settings'; import {getMetadata} from './profiles'; import {publish} from './relays'; import {parseJSON} from './media'; const contactHistoryMap: { [pubkey: string]: Event[]; } = {}; /** * returns true if user is following pubkey */ export const isFollowing = (id: string) => { const following = contactHistoryMap[config.pubkey]?.at(0); if (!following) { return false; } return following.tags.some(([tag, value]) => tag === 'p' && value === id); }; export const updateFollowBtn = (pubkey: string) => { const followBtn = getViewElem('followBtn'); if (followBtn) { const hasContact = isFollowing(pubkey); followBtn.textContent = hasContact ? 'unfollow' : 'follow'; followBtn.classList.remove('primary', 'secondary'); followBtn.classList.add(hasContact ? 'secondary' : 'primary'); followBtn.hidden = false; } }; const updateFollowing = (evt: Event) => { const view = getViewOptions(); if (evt.pubkey === config.pubkey) { localStorage.setItem('follwing', JSON.stringify(evt)); } if (view.type === 'profile') { updateFollowBtn(view.id); if (view.id === evt.pubkey) { // update following link const following = getViewElem('following') as HTMLElement; if (following) { const count = evt.tags.filter(isPTag).length; const anchor = elem('a', { data: {following: evt.pubkey}, href: `/${evt.id}`, title: dateTime.format(evt.created_at * 1000), }, [ 'following ', elem('span', {className: 'highlight'}, count), ]); following.replaceWith(anchor); setViewElem('following', anchor); } } } }; export const setContactList = (evt: Event) => { const contactHistory = contactHistoryMap[evt.pubkey]; if (!contactHistory) { contactHistoryMap[evt.pubkey] = [evt]; updateFollowing(evt); return; } if (contactHistory.find(({id}) => id === evt.id)) { return; } contactHistory.unshift(evt); updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist? }; /** * findChanges * returns added and removed contacts list of P tags, ignores any tag other than 'p' */ const findChanges = (current: Event, previous: Event) => { const previousContacts = previous.tags.join('\n'); // filter for p tags first? const currentContacts = current.tags.join('\n'); const addedContacts = current.tags.filter(([tag, pubkey]) => tag === 'p' && !previousContacts.includes(pubkey)); const removedContacts = previous.tags.filter(([tag, pubkey]) => tag === 'p' && !currentContacts.includes(pubkey)); return [addedContacts, removedContacts]; }; export const resetContactList = (pubkey: string) => { delete contactHistoryMap[pubkey]; }; export const getContactUpdateMessage = ( addedList: string[][], removedList: string[][], ) => { const content = []; if (addedList.length && addedList[0]) { const pubkey = addedList[0][1]; const {userName} = getMetadata(pubkey); const npub = nip19.npubEncode(pubkey); content.push( 'follows ', elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName), ); } if (addedList.length > 1) { content.push(` (+ ${addedList.length - 1} others)`); } if (removedList?.length > 0) { if (content.length) { content.push(' and'); } content.push(' unfollowed '); if (removedList.length > 1) { content.push(`${removedList.length}`); } else { const removedPubkey = removedList[0][1]; const {userName: removeduserName} = getMetadata(removedPubkey); const removedNpub = nip19.npubEncode(removedPubkey); content.push(elem('a', {href: `/${removedNpub}`, data: {profile: removedPubkey}}, removeduserName)); } } return content; }; export const updateContactList = (evt: Event) => { const contactHistory = contactHistoryMap[evt.pubkey]; if (contactHistory.length === 1) { return [contactHistory[0].tags.filter(isPTag)]; } const pos = contactHistory.findIndex(({id}) => id === evt.id); if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update return findChanges(evt, contactHistory[pos + 1]); } // update existing contact entries contactHistory .slice(0, -1) .forEach((entry, i) => { const previous = contactHistory[i + 1]; const [added, removed] = findChanges(entry, previous); const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`); const updated = getContactUpdateMessage(added, removed); contactNote?.replaceChildren(...updated); }); return [evt.tags.filter(isPTag)]; }; export const getContacts = () => { const following = contactHistoryMap[config.pubkey]?.at(0); // TODO: ensure newest contactlist if (following) { return following.tags .filter(isPTag) .map(([, pubkey]) => pubkey); } const followingFromStorage = localStorage.getItem('follwing'); if (followingFromStorage) { const follwingData = parseJSON(followingFromStorage) as Event; // TODO: ensure signature matches if (follwingData && follwingData.pubkey === config.pubkey) { return follwingData.tags .filter(isPTag) .map(([, pubkey]) => pubkey); } } return []; }; const updateContactTags = ( followeeID: string, currentContactList: Event | undefined, ) => { if (!currentContactList?.tags) { return [['p', followeeID], ['p', config.pubkey]]; } if (currentContactList.tags.some(([tag, id]) => tag === 'p' && id === followeeID)) { return currentContactList.tags .filter(([tag, id]) => tag === 'p' && id !== followeeID); } return [ ['p', followeeID], ...currentContactList.tags .filter(isNotNonceTag), ]; }; export const followContact = async (id: string) => { const followBtn = getViewElem('followBtn') as HTMLButtonElement; const statusElem = getViewElem('followStatus') as HTMLElement; if (!followBtn || !statusElem) { return; } const following = contactHistoryMap[config.pubkey]?.at(0); const unsignedEvent = { kind: 3, pubkey: config.pubkey, content: '', tags: updateContactTags(id, following), created_at: Math.floor(Date.now() * 0.001), }; followBtn.disabled = true; const newContactListEvent = await powEvent(unsignedEvent, { difficulty: config.difficulty, statusElem, timeout: config.timeout, }).catch(console.warn); if (!newContactListEvent) { statusElem.textContent = ''; statusElem.hidden = false; followBtn.disabled = false; return; } const privatekey = localStorage.getItem('private_key'); if (!privatekey) { statusElem.textContent = 'no private key to sign'; statusElem.hidden = false; followBtn.disabled = false; return; } const sig = signEvent(newContactListEvent, privatekey); // TODO: validateEvent? if (sig) { statusElem.textContent = 'publishing…'; publish({...newContactListEvent, sig}, (relay, error) => { if (error) { return console.error(error, relay); } statusElem.hidden = true; followBtn.disabled = false; console.info(`event published by ${relay}`); }); } };