diff --git a/src/contacts.ts b/src/contacts.ts index 9ff8ade..517040e 100644 --- a/src/contacts.ts +++ b/src/contacts.ts @@ -13,22 +13,28 @@ const contactHistoryMap: { [pubkey: string]: Event[]; } = {}; +const hasOwnContactList = () => { + return !!contactHistoryMap[config.pubkey]; +}; + /** * returns true if user is following pubkey */ -export const isFollowing = (id: string) => { +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 === id); + return following.tags.some(([tag, value]) => tag === 'p' && value === pubkey); }; export const updateFollowBtn = (pubkey: string) => { - const followBtn = getViewElem('followBtn'); - if (followBtn) { + const followBtn = getViewElem(`followBtn-${pubkey}`); + const view = getViewOptions(); + if (followBtn && (view.type === 'contacts' || view.type === 'profile')) { const hasContact = isFollowing(pubkey); - followBtn.textContent = hasContact ? 'unfollow' : 'follow'; + 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; @@ -40,25 +46,49 @@ const updateFollowing = (evt: Event) => { 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); + 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) { + // 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/${nip19.npubEncode(evt.pubkey)}`, + title: dateTime.format(evt.created_at * 1000), + }, [ + 'following ', + elem('span', {className: 'highlight'}, count), + ]); + following.replaceWith(anchor); + setViewElem('following', anchor); + } + } + break; + } +}; + +export const refreshFollowing = (id: string) => { + if (contactHistoryMap[id]?.at(0)) { + updateFollowing(contactHistoryMap[id][0]); } }; @@ -148,12 +178,29 @@ export const updateContactList = (evt: Event) => { 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); +/** + * 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) { @@ -186,9 +233,9 @@ const updateContactTags = ( ]; }; -export const followContact = async (id: string) => { - const followBtn = getViewElem('followBtn') as HTMLButtonElement; - const statusElem = getViewElem('followStatus') as HTMLElement; +export const followContact = async (pubkey: string) => { + const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement; + const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement; if (!followBtn || !statusElem) { return; } @@ -197,7 +244,7 @@ export const followContact = async (id: string) => { kind: 3, pubkey: config.pubkey, content: '', - tags: updateContactTags(id, following), + tags: updateContactTags(pubkey, following), created_at: Math.floor(Date.now() * 0.001), }; @@ -234,5 +281,4 @@ export const followContact = async (id: string) => { console.info(`event published by ${relay}`); }); } - }; diff --git a/src/index.html b/src/index.html index dd0e1c0..beaf0a6 100644 --- a/src/index.html +++ b/src/index.html @@ -6,7 +6,7 @@ nostr - + @@ -110,5 +110,5 @@ - + diff --git a/src/main.ts b/src/main.ts index aa2a068..b0ddcec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,15 +4,15 @@ import {elem} from './utils/dom'; import {bounce} from './utils/time'; import {isWssUrl} from './utils/url'; import {closeSettingsView, config, toggleSettingsView} from './settings'; -import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts} from './subscriptions' +import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts, subContactList} from './subscriptions' import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events'; import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view'; import {handleReaction, handleUpvote} from './reactions'; import {closePublishView, openWriteInput, togglePublishView} from './write'; import {handleMetadata, renderProfile} from './profiles'; -import {followContact, getContactUpdateMessage, getContacts, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts'; +import {followContact, getContactUpdateMessage, getContacts, getOwnContacts, refreshFollowing, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; -import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; +import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -38,6 +38,18 @@ const renderNote = ( setViewElem(evt.id, article); }; +const renderContact = (pubkey: string) => { + if (getViewElem(`contact-${pubkey}`)) { // contact already in view + updateFollowBtn(pubkey); + return; + } + const contact = createContact(pubkey); + if (contact) { + getViewContent().append(contact); + setViewElem(`contact-${pubkey}`, contact); + } +}; + const hasEnoughPOW = ( [tag, , commitment]: string[], eventId: string @@ -64,12 +76,13 @@ const renderFeed = bounce(() => { ] .sort(sortByCreatedAt) .reverse() - .forEach(renderNote); // render in-reply-to + .forEach(renderNote); renderProfile(view.id); + refreshFollowing(view.id); break; case 'home': - const ids = getContacts(); + const ids = getOwnContacts(); [ ...textNoteList .filter(note => ids.includes(note.pubkey)), @@ -95,6 +108,10 @@ const renderFeed = bounce(() => { .reverse() .forEach(renderNote); break; + case 'contacts': + getContacts(view.id) + .forEach(renderContact); + break; } }, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) @@ -167,30 +184,35 @@ const handleContactList = (evt: Event, relay: string) => { // TODO: if newer and view.type === 'home' rerenderFeed() setContactList(evt); const view = getViewOptions(); + if (getViewElem(evt.id)) { + return; + } if ( - getViewElem(evt.id) - || view.type !== 'profile' - || view.id !== evt.pubkey + view.type === 'contacts' + && [view.id, config.pubkey].includes(evt.pubkey) // render if contact-list is from current users or current view ) { + renderFeed(); return; } - // use find instead of sort? - const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); - const closestNote = getViewElem(closestTextNotes[0].id); - if (!closestNote) { - // no close note, try later - setTimeout(() => handleContactList(evt, relay), 1500); - return; - }; - const [addedContacts, removedContacts] = updateContactList(evt); - const content = getContactUpdateMessage(addedContacts, removedContacts); - if (!content.length) { - // P same as before, maybe only evt.content or 'a' tags changed? - return; + if (view.type === 'profile' && view.id === evt.pubkey) { + // use find instead of sort? + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + const closestNote = getViewElem(closestTextNotes[0].id); + if (!closestNote) { + // no close note, try later + setTimeout(() => handleContactList(evt, relay), 1500); + return; + }; + const [addedContacts, removedContacts] = updateContactList(evt); + const content = getContactUpdateMessage(addedContacts, removedContacts); + if (!content.length) { + // P same as before, maybe only evt.content or 'a' tags changed? + return; + } + const art = renderUpdateContact({...evt, content}, relay); + closestNote.after(art); + setViewElem(evt.id, art); } - const art = renderUpdateContact({...evt, content}, relay); - closestNote.after(art); - setViewElem(evt.id, art); }; const handleRecommendServer = (evt: Event, relay: string) => { @@ -243,10 +265,9 @@ const onEvent = (evt: Event, relay: string) => { // subscribe and change view const route = (path: string) => { - const contactList = getContacts(); if (path === '/') { + const contactList = getOwnContacts(); if (contactList.length) { - const {pubkey} = config; subPubkeys(contactList, onEvent); view(`/`, {type: 'home'}); } else { @@ -278,18 +299,25 @@ const route = (path: string) => { console.warn(`type ${type} not yet supported`); } renderFeed(); + } else if (path.length === 73 && path.match(/^\/contacts\/npub[0-9a-z]+$/)) { + const contactNpub = path.slice(10); + const {type: contactType, data: contactPubkey} = nip19.decode(contactNpub); + if (contactType === 'npub') { + subContactList(contactPubkey, onEvent); + view(path, {type: 'contacts', id: contactPubkey}); + } } else if (path.length === 65) { const eventID = path.slice(1); subEventID(eventID, onEventDetails); view(path, {type: 'event', id: eventID}); } else { - console.warn('no support for ', path) + console.warn('no support for ', path); } }; // onload -subOwnContacts(onEvent); route(location.pathname); +subOwnContacts(onEvent); // subscribe after route as routing unsubscribes current subs // only push a new entry if there is no history onload if (!history.length) { @@ -317,6 +345,7 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => { || href.startsWith('/feed') || href.startsWith('/note') || href.startsWith('/npub') + || href.startsWith('/contacts/npub') || (href.startsWith('/') && href.length === 65) ) { route(href); diff --git a/src/profiles.ts b/src/profiles.ts index bfe2a12..08f77e3 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,7 +1,7 @@ import {Event} from 'nostr-tools'; import {elem, elemCanvas, parseTextContent} from './utils/dom'; import {getNoxyUrl} from './utils/url'; -import {getViewElem} from './view'; +import {getViewElem, getViewOptions} from './view'; import {parseJSON} from './media'; import {sortByCreatedAt} from './events'; @@ -87,10 +87,22 @@ export const handleMetadata = (evt: Event, relay: string) => { username.classList.add('mbox-kind0-name'); }); } + if (metadata.about) { + const about = getViewElem(`about-${evt.pubkey}`); + if (about) { + const view = getViewOptions(); + about.replaceChildren(...parseTextContent( + view.type === 'contacts' + ? metadata.about.split('\n')[0] + : metadata.about + )[0]); + } + } }; export const getMetadata = (pubkey: string) => { const user = profileMap[pubkey]; + const about = user?.about; const name = user?.name; const userName = name || pubkey.slice(0, 8); // const userImg = user?.picture; @@ -100,7 +112,7 @@ export const getMetadata = (pubkey: string) => { src: userImg, title: `${userName} on ${host} ${userAbout}`, }) : */ elemCanvas(pubkey); - return {img, name, userName}; + return {about, img, name, userName}; }; export const renderProfile = (pubkey: string) => { @@ -118,12 +130,11 @@ export const renderProfile = (pubkey: string) => { header.append(elem('h1', {}, metadata.name)); } } - const detail = getViewElem('detail'); - if (metadata.about) { - if (!detail.children.length) { - const [content] = parseTextContent(metadata.about); - detail?.append(...content); - } + console.log('render detail') + const detail = getViewElem(`detail-${pubkey}`); + if (metadata.about && !detail.children.length) { + const [content] = parseTextContent(metadata.about); + detail?.append(...content); } if (metadata.website) { const website = detail.querySelector('[data-website]'); diff --git a/src/styles/cards.css b/src/styles/cards.css index 0dd4ef2..edb06c5 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -19,6 +19,7 @@ background-color: var(--bgcolor-textinput); border-radius: var(--profileimg-size); flex-basis: var(--profileimg-size); + flex-shrink: 0; height: var(--profileimg-size); margin-right: var(--gap-half); max-height: var(--profileimg-size); @@ -57,9 +58,12 @@ a.mbox-img:focus { .mbox-replies .mbox-replies .mbox-img + .mbox-body { --max-width: calc(100% - var(--profileimg-size) + var(--gap-half)); } +.mbox-contact .mbox-img + .mbox-body { + --max-width: calc(100% - var(--profileimg-size) - var(--gap-half) - 90px); +} .mbox-header { - align-items: start; + align-items: baseline; display: flex; gap: var(--gap-quarter); justify-content: space-between; @@ -72,6 +76,7 @@ a.mbox-img:focus { text-decoration: none; } .mbox-header small { + color: var(--color-accent); white-space: nowrap; } @@ -81,6 +86,35 @@ a.mbox-img:focus { color: var(--color-accent); } +.mbox-contact { + align-items: start; /* TODO: maybe all .mbox element should have align-items start */ + flex-wrap: nowrap; + padding: var(--gap-half); +} +.mbox-contact .mbox-header { + justify-content: start; +} +.mbox-contact .mbox-body { + flex-basis: 100%; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; /* with this mbox-content displays text on one line cutting off text-overflo... */ + padding-bottom: 0; +} +.mbox-contact .mbox-content { + overflow: clip; + padding-right: var(--gap-quarter); + text-overflow: ellipsis; + white-space: nowrap; +} + +.mbox-cta { + align-items: center; + align-self: center; + display: flex; + white-space: nowrap; +} + .mbox-updated-contact, .mbox-recommend-server { padding-bottom: var(--gap-quarter); diff --git a/src/styles/form.css b/src/styles/form.css index 6ca6828..a809d2d 100644 --- a/src/styles/form.css +++ b/src/styles/form.css @@ -146,6 +146,10 @@ button:active { .secondary { background-color: transparent; } +.secondary:disabled { + border-color: var(--color-accent); + color: var(--color-accent); +} button:focus { } diff --git a/src/styles/view.css b/src/styles/view.css index 81260dd..a70c475 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -159,7 +159,10 @@ nav a { } .hero-title h1 { flex-grow: 1; + font-size: 2.1rem; + line-height: 1.285714285714286; margin-bottom: 0; + margin-top: 2rem; padding-left: var(--extra-space); } .hero-title button { @@ -173,8 +176,9 @@ nav a { .hero .hero-npub { color: var(--color-accent); - font-size: 1.1rem; display: block; + font-size: 1.1rem; + line-height: 1.36363636; max-width: 100%; overflow: clip; text-align: center; diff --git a/src/subscriptions.ts b/src/subscriptions.ts index 8a70215..9282f00 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -1,5 +1,5 @@ import {Event} from 'nostr-tools'; -import {getReplyTo, hasEventTag, isMention} from './events'; +import {getReplyTo, hasEventTag, isMention, isPTag} from './events'; import {config} from './settings'; import {sub, subOnce, unsubAll} from './relays'; @@ -288,3 +288,41 @@ export const subOwnContacts = (onEvent: SubCallback) => { unsub: true, }); }; + +export const subContactList = ( + pubkey: string, + onEvent: SubCallback, +) => { + unsubAll(); + const pubkeys = new Set(); + let newestEvent = 0; + sub({ + cb: (evt: Event, relay: string) => { + if (evt.created_at <= newestEvent) { + return; + } + newestEvent = evt.created_at; + const newPubkeys = evt.tags + .filter(isPTag) + .filter(([, p]) => !pubkeys.has(p)) + .map(([, p]) => { + pubkeys.add(p); + return p + }); + subOnce({ + cb: onEvent, + filter: { + authors: newPubkeys, + kinds: [0], + }, + relay, + }); + onEvent(evt, relay); + }, + filter: { + authors: [pubkey], + kinds: [3], + limit: 1, + }, + }); +}; diff --git a/src/system.ts b/src/system.ts index 832b076..1bb2b96 100644 --- a/src/system.ts +++ b/src/system.ts @@ -88,7 +88,7 @@ export const powEvent = ( statusElem.replaceChildren('working…', cancelBtn); statusElem.hidden = false; return new Promise((resolve, reject) => { - const worker = new Worker('./worker.js'); + const worker = new Worker('/worker.js'); const onCancel = () => { worker.terminate(); diff --git a/src/template.ts b/src/template.ts index 60bc842..d030a30 100644 --- a/src/template.ts +++ b/src/template.ts @@ -16,6 +16,9 @@ export type ViewTemplateOptions = { } | { type: 'profile'; id: string; +} | { + type: 'contacts'; + id: string; } | { type: 'event'; id: string; @@ -32,7 +35,8 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { case 'profile': const pubkey = options.id; const npub = nip19.npubEncode(pubkey); - const detail = elem('p'); + const about = elem('span'); + const detail = elem('p', {}, about); const followStatus = elem('small'); const followBtn = elem('button', { className: 'primary', @@ -51,15 +55,18 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { elem('footer', {}, following), ]); dom.header = profileHeader; - dom.detail = detail; + dom[`about-${pubkey}`] = about; + dom[`detail-${pubkey}`] = detail; dom.following = following; - dom.followStatus = followStatus; - dom.followBtn = followBtn; + dom[`followStatus-${pubkey}`] = followStatus; + dom[`followBtn-${pubkey}`] = followBtn; content.append(profileHeader); document.title = pubkey; break; case 'note': break; + case 'contacts': + break; case 'event': const id = options.id; content.append( diff --git a/src/ui.ts b/src/ui.ts index 9e91e0b..c54a501 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,14 +2,15 @@ import {Event, nip19} from 'nostr-tools'; import {Children, elem, elemArticle, parseTextContent} from './utils/dom'; import {dateTime, formatTime} from './utils/time'; import {/*validatePow,*/ sortByCreatedAt} from './events'; -import {setViewElem} from './view'; +import {getViewElem, getViewOptions, setViewElem} from './view'; import {config} from './settings'; import {getReactions, getReactionContents} from './reactions'; import {openWriteInput} from './write'; // import {linkPreview} from './media'; +import {parseJSON} from './media'; import {getMetadata} from './profiles'; import {EventWithNip19, replyList} from './notes'; -import {parseJSON} from './media'; +import {isFollowing} from './contacts'; setInterval(() => { document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { @@ -179,3 +180,46 @@ export const renderEventDetails = (evt: Event, relay: string) => { data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey} }); }; + +export const createContact = (pubkey: string) => { + const {about: aboutContent, img, name, userName} = getMetadata(pubkey); + const npub = nip19.npubEncode(pubkey); + const view = getViewOptions(); + if (view.type !== 'contacts') { + return null; + } + const isMe = config.pubkey === pubkey; + const isCurrentUser = view.id === pubkey; + const hasContact = isFollowing(pubkey); + const followStatus = elem('small'); + const followBtn = elem('button', { + className: hasContact ? 'secondary' : 'primary', + ...(isMe && {disabled: true}), + name: 'follow', + data: {id: pubkey} + }, hasContact ? (isMe ? 'following' : 'unfollow') : 'follow'); + const about = elem('div', {className: 'mbox-content'}, aboutContent); + setViewElem(`about-${pubkey}`, about); + setViewElem(`followStatus-${pubkey}`, followStatus); + setViewElem(`followBtn-${pubkey}`, followBtn); + return elemArticle([ + elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img), + elem('div', {className: 'mbox-body'}, [ + elem('header', {className: 'mbox-header'}, [ + elem('a', { + className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, + data: {profile: pubkey}, + href: `/${npub}`, + }, name || userName), + (isMe || isCurrentUser) + ? elem('small', {}, isMe ? '(your user)' : '(current user)') + : null, + ]), + about, + ]), + elem('div', {className: 'mbox-cta'}, [followStatus, followBtn]), + ], { + className: 'mbox-contact', + data: {pubkey}, + }); +}; diff --git a/src/view.ts b/src/view.ts index 82e1d3e..2f59a88 100644 --- a/src/view.ts +++ b/src/view.ts @@ -60,7 +60,7 @@ type GetViewOptions = () => ViewTemplateOptions; /** * get options for current view - * @returns {id: 'home' | 'feed' | 'profile' | 'note' | 'event', id?: string} + * @returns {id: 'home' | 'feed' | 'profile' | 'note' | 'contacts' | 'event', id?: string} */ export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};