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[]; } = {}; const hasOwnContactList = () => { return !!contactHistoryMap[config.pubkey]; }; /** * returns true if user is following pubkey */ export const isFollowing = (pubkey: string) => { const following = contactHistoryMap[config.pubkey]?.at(0); if (!following) { return false; } return following.tags.some(([tag, value]) => tag === 'p' && value === pubkey); }; export const updateFollowBtn = (pubkey: string) => { const followBtn = getViewElem(`followBtn-${pubkey}`); const view = getViewOptions(); if (followBtn && (view.type === 'contacts' || view.type === 'profile')) { const hasContact = isFollowing(pubkey); const isMe = config.pubkey === pubkey; followBtn.textContent = isMe ? 'following' : 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)); } switch(view.type) { case 'contacts': if (hasOwnContactList()) { const lastContactList = contactHistoryMap[config.pubkey]?.at(1); if (lastContactList) { const [added, removed] = findChanges(evt, lastContactList); [ ...added.map(([, pubkey]) => pubkey), ...removed.map(([, pubkey]) => pubkey), ].forEach(updateFollowBtn); } else { evt.tags .filter(isPTag) .forEach(([, pubkey]) => updateFollowBtn(pubkey)); } } break; case 'profile': updateFollowBtn(view.id); if (view.id === evt.pubkey) { const npub = nip19.npubEncode(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: `/contacts/${npub}`, title: dateTime.format(evt.created_at * 1000), }, [ 'following ', elem('span', {className: 'highlight'}, count), ]); following.replaceWith(anchor); setViewElem('following', anchor); } let timeline = getViewElem('timeline'); if (!timeline) { timeline = elem('a', {href: `/timeline/${npub}`}, 'timeline'); getViewElem('header').querySelector('footer')?.append(timeline); setViewElem('timeline', timeline); } } break; } }; export const refreshFollowing = (id: string) => { if (contactHistoryMap[id]?.at(0)) { updateFollowing(contactHistoryMap[id][0]); } }; 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)]; }; /** * returns list of pubkeys the given pubkey is following * @param pubkey * @returns {String[]} pubkeys */ export const getContacts = (pubkey: string) => { const following = contactHistoryMap[pubkey]?.at(0); if (!following) { return []; } return following.tags .filter(isPTag) .map(([, pubkey]) => pubkey); }; /** * returns list of pubkeys the user is following, if none found it will try from localstorage * @returns {String[]} pubkeys */ export const getOwnContacts = () => { const following = getContacts(config.pubkey); if (following.length) { return following; } 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 (pubkey: string) => { const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement; const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement; if (!followBtn || !statusElem) { return; } const following = contactHistoryMap[config.pubkey]?.at(0); const unsignedEvent = { kind: 3, pubkey: config.pubkey, content: '', tags: updateContactTags(pubkey, 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}`); }); } };