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/main.ts b/src/main.ts index ecbda2b..eedcf7e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, vie import {handleReaction, handleUpvote} from './reactions'; import {closePublishView, openWriteInput, togglePublishView} from './write'; import {handleMetadata, renderProfile} from './profiles'; -import {getContactUpdateMessage, setContactList, updateContactList} from './contacts'; +import {followContact, getContactUpdateMessage, setContactList, updateContactList} from './contacts'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; @@ -317,6 +317,9 @@ const handleButton = (button: HTMLButtonElement) => { const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); note && handleUpvote(note); break; + case 'follow': + followContact(id); + break; } } // const container = e.target.closest('[data-append]'); diff --git a/src/styles/cards.css b/src/styles/cards.css index aa7c67a..0dd4ef2 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -49,9 +49,6 @@ a.mbox-img:focus { padding-bottom: var(--gap-half); word-break: break-word; } -.mbox-content a { - text-decoration: underline; -} .mbox-img + .mbox-body { --max-width: calc(100% - var(--profileimg-size) - var(--gap-half)); flex-basis: var(--max-width); @@ -66,28 +63,38 @@ a.mbox-img:focus { display: flex; gap: var(--gap-quarter); justify-content: space-between; - margin-bottom: .2rem; - margin-top: 0; + margin: .1rem 0; min-height: 1.8rem; } .mbox-header a { font-size: var(--font-small); + line-height: var(--lineheight-small); + text-decoration: none; } .mbox-header small { white-space: nowrap; } .mbox-username { - font-weight: 600; } .mbox-kind0-name { color: var(--color-accent); } +.mbox-updated-contact, +.mbox-recommend-server { + padding-bottom: var(--gap-quarter); +} .mbox-updated-contact .mbox-body, .mbox-recommend-server .mbox-body { display: block; font-size: var(--font-small); + padding-bottom: var(--gap-quarter); + padding-top: 0; +} +.mbox-updated-contact + .mbox-updated-contact, +.mbox-recommend-server + .mbox-updated-contact { + padding-top: 0; } .mbox-updated-contact .mbox-header, diff --git a/src/styles/form.css b/src/styles/form.css index 409e2bd..fd5edf5 100644 --- a/src/styles/form.css +++ b/src/styles/form.css @@ -130,6 +130,18 @@ button { outline-offset: 1px; word-break: normal; } +button:active { + --bg-color: rgb(13, 74, 139); + --border-color: rgb(13, 74, 139); +} + +.primary, +.secondary { + padding: .8rem 2.4rem; +} +.secondary { + background-color: transparent; +} button:focus { } diff --git a/src/styles/main.css b/src/styles/main.css index ebf1b28..e250050 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -16,6 +16,7 @@ --focus-outline-width: 2px; --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --font-small: 1.2rem; + --lineheight-small: 1.5; --gap: 2.4rem; --gap-half: 1.2rem; --gap-quarter: .6rem; @@ -41,12 +42,16 @@ @media (prefers-color-scheme: light) { html { - --color: rgb(93, 93, 93); - --color-accent: rgb(130, 130, 130); + --color: rgb(43, 43, 43); + --color-accent: rgb(118, 118, 118); + --color-accent-line: rgb(163, 163, 163); --color-danger: #0e0e0e; + --color-visited: #7467c4; + --color-visited-line: #9083e3; + --color-inverse: #fff; --bgcolor: #fff; --bgcolor-nav: gainsboro; - --bgcolor-accent: #7badfc; + --bgcolor-accent: #5194ff; --bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger-input: rgba(255 255 255 / .85); --bgcolor-inactive: #bababa; @@ -56,15 +61,19 @@ @media (prefers-color-scheme: dark) { html { - --color: #e3e3e3; + --color: #d9d9d9; --color-accent: #828282; + --color-accent-line: #737373; --color-danger: #e3e3e3; + --color-visited: #796ae3; + --color-visited-line: #5d4fce; + --color-inverse: #101010; --bgcolor: #101010; --bgcolor-nav: rgb(31, 22, 51); - --bgcolor-accent: rgb(16, 93, 176); + --bgcolor-accent: rgb(16, 77, 176); --bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger-input: rgba(0 0 0 / .5); - --bgcolor-inactive: #202122; + --bgcolor-inactive: #353638; --bgcolor-textinput: #0e0e0e; } @@ -92,7 +101,7 @@ body { @media (orientation: portrait) { body { font-size: 1.4rem; - line-height: 1.5; + line-height: 1.428571428571429; } } @@ -133,16 +142,25 @@ img { a { color: var(--color-accent); - text-decoration: none; + text-decoration-color: var(--color-accent-line); + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: 2px; } -a:focus { +a .highlight { + color: var(--color); +} + +a:focus, +button:focus { border-radius: var(--focus-border-radius); outline: var(--focus-outline); outline-offset: 0; } a:visited { - color: #8377ce; + color: var(--color-visited); + text-decoration-color: var(--color-visited-line); } nav a:visited { color: inherit; @@ -166,6 +184,7 @@ dl { } dt { + color: var(--color-accent); grid-column-start: 1; } diff --git a/src/styles/view.css b/src/styles/view.css index 03c8a2f..9b1dda4 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -135,16 +135,29 @@ nav a { --extra-space: calc(var(--profileimg-size) + var(--gap-half)); padding: var(--gap-half); } - -.hero h1 { +.hero-title { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: var(--gap-half); + justify-content: end; + max-width: var(--content-width); +} +.hero-title h1 { + flex-grow: 1; + margin-bottom: 0; padding-left: var(--extra-space); } +.hero-title button { + line-height: 1; +} .hero p { + max-width: calc(var(--content-width) - var(--extra-space)); padding-left: var(--extra-space); } -.hero small { +.hero .hero-npub { color: var(--color-accent); font-size: 1.1rem; display: block; @@ -155,7 +168,7 @@ nav a { white-space: nowrap; } @media (min-width: 54ch) { - .hero small { + .hero .hero-npub { padding-left: var(--extra-space); text-align: left; } @@ -164,3 +177,7 @@ nav a { .hero footer { padding-left: var(--extra-space); } + +.hero footer a { + text-decoration: none; +} diff --git a/src/subscriptions.ts b/src/subscriptions.ts index b70028b..82979ef 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -194,9 +194,9 @@ export const subProfile = ( sub({ cb: onEvent, filter: { - authors: [pubkey], + authors: [pubkey, config.pubkey], kinds: [3], - limit: 3, + limit: 6, }, }); }, 100); diff --git a/src/template.ts b/src/template.ts index 8d745b0..20cb28b 100644 --- a/src/template.ts +++ b/src/template.ts @@ -26,11 +26,19 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { case 'profile': const pubkey = options.id; const detail = elem('p'); + const followStatus = elem('small'); + const followBtn = elem('button', { + className: 'primary', + name: 'follow', + data: {'id': options.id} + }, 'follow'); const following = elem('span'); const profileHeader = elem('header', {className: 'hero'}, [ elem('small', {className: 'hero-npub'}, nip19.npubEncode(pubkey)), elem('div', {className: 'hero-title'}, [ elem('h1', {}, pubkey), + followStatus, + followBtn, ]), detail, elem('footer', {}, following), @@ -38,6 +46,8 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { dom.header = profileHeader; dom.detail = detail; dom.following = following; + dom.followStatus = followStatus; + dom.followBtn = followBtn; content.append(profileHeader); document.title = pubkey; break; diff --git a/src/ui.ts b/src/ui.ts index 63564f3..9e91e0b 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -100,8 +100,6 @@ export const renderUpdateContact = ( ' ', elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content), ]), - elem('div', {className: 'mbox-content'}, [ - ]), ]), ], { className: 'mbox-updated-contact', @@ -133,13 +131,13 @@ export const renderEventDetails = (evt: Event, relay: string) => { const {img, name, userName} = getMetadata(evt.pubkey); const npub = nip19.npubEncode(evt.pubkey); - let content = parseJSON(evt.content) + let content = (![1, 7].includes(evt.kind) && evt.content !== '') ? parseJSON(evt.content) : (evt.content || ''); switch (typeof content) { case 'object': content = JSON.stringify(content, null, 2); break; default: - content = `${evt.content}`; + content = `${content}`; } const body = elem('div', {className: 'mbox-body'}, [ elem('header', {className: 'mbox-header'}, [