diff --git a/src/contacts.ts b/src/contacts.ts index 9491266..9ff8ade 100644 --- a/src/contacts.ts +++ b/src/contacts.ts @@ -1,29 +1,69 @@ -import {Event, nip19} from 'nostr-tools'; +import {Event, nip19, signEvent} from 'nostr-tools'; import {elem} from './utils/dom'; import {dateTime} from './utils/time'; -import {isPTag, sortByCreatedAt} from './events'; -import {getViewContent} from './view'; +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[] + [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 following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`); - if (following) { - const count = evt.tags.filter(isPTag).length; - const anchor = elem('a', { - data: {following: evt.pubkey}, - href: `/${evt.id}`, - title: dateTime.format(new Date(evt.created_at * 1000)), - }, `following ${count}`); - following.replaceWith(anchor); + 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) => { - let contactHistory = contactHistoryMap[evt.pubkey]; + const contactHistory = contactHistoryMap[evt.pubkey]; if (!contactHistory) { contactHistoryMap[evt.pubkey] = [evt]; updateFollowing(evt); @@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => { if (contactHistory.find(({id}) => id === evt.id)) { return; } - contactHistory.push(evt); - contactHistory.sort(sortByCreatedAt); - updateFollowing(contactHistory[0]); + contactHistory.unshift(evt); + updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist? }; /** @@ -49,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => { return [addedContacts, removedContacts]; }; -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) { // oldest known contact-list update - // 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)]; - } - return findChanges(evt, contactHistory[pos + 1]); +export const resetContactList = (pubkey: string) => { + delete contactHistoryMap[pubkey]; }; export const getContactUpdateMessage = ( @@ -76,7 +97,6 @@ export const getContactUpdateMessage = ( removedList: string[][], ) => { const content = []; - // console.log(addedContacts) if (addedList.length && addedList[0]) { const pubkey = addedList[0][1]; const {userName} = getMetadata(pubkey); @@ -90,7 +110,129 @@ export const getContactUpdateMessage = ( content.push(` (+ ${addedList.length - 1} others)`); } if (removedList?.length > 0) { - content.push(elem('small', {}, ` and unfollowed ${removedList.length}`)); + 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}`); + }); + } + +}; diff --git a/src/events.ts b/src/events.ts index dc79271..ed83306 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,9 +1,11 @@ import {Event} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; +export const isEvent = (evt?: T): evt is T => evt !== undefined; export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; export const isPTag = ([tag]: string[]) => tag === 'p'; export const hasEventTag = (tag: string[]) => tag[0] === 'e'; +export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce'; /** * validate proof-of-work of a nostr event per nip-13. diff --git a/src/index.html b/src/index.html index 42e5f25..3fe9195 100644 --- a/src/index.html +++ b/src/index.html @@ -24,8 +24,8 @@ write a new note
- - + +
@@ -42,7 +42,7 @@
- +
@@ -86,8 +86,8 @@
- - + +