From 36327f615155881af47fbab15462c36e8d3c8fd3 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 9 Aug 2023 15:20:53 +0200 Subject: [PATCH] contact: add follow and unfollow support this creates a kind 3 event that includes a list of profiles that the user is following. the feed is still the public global feed and individual feed with only events from followed pubkeys will be added in the next commit. also added proper primary and secondary button styles. --- src/contacts.ts | 220 +++++++++++++++++++++++++++++++++++-------- src/events.ts | 2 + src/index.html | 10 +- src/main.ts | 5 +- src/styles/cards.css | 19 ++-- src/styles/form.css | 26 ++++- src/styles/main.css | 39 ++++++-- src/styles/view.css | 25 ++++- src/subscriptions.ts | 4 +- src/template.ts | 10 ++ src/ui.ts | 6 +- 11 files changed, 290 insertions(+), 76 deletions(-) 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 @@
- - + +