From cd7dfa3f194b2b948c64f169dc3fad4aab308d06 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 2 Aug 2023 11:40:08 +0200 Subject: [PATCH] profiles: improve profile view - add about to profile header - set document title to profile name - refactor how profile metadata --- src/media.ts | 2 +- src/profiles.ts | 214 +++++++++++++++++--------------------------- src/settings.ts | 2 +- src/styles/view.css | 10 ++- src/ui.ts | 27 ++++-- src/view.ts | 31 +++++-- 6 files changed, 136 insertions(+), 150 deletions(-) diff --git a/src/media.ts b/src/media.ts index 5653aa6..9d8246a 100644 --- a/src/media.ts +++ b/src/media.ts @@ -1,7 +1,7 @@ import { elem } from './utils/dom'; import { getNoxyUrl } from './utils/url'; -export const parseContent = (content: string): unknown => { +export const parseJSON = (content: string): unknown => { try { return JSON.parse(content); } catch(err) { diff --git a/src/profiles.ts b/src/profiles.ts index 70c62e2..104e7f3 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,59 +1,75 @@ import {Event} from 'nostr-tools'; -import {elem, elemCanvas} from './utils/dom'; -import {getHost, getNoxyUrl} from './utils/url'; +import {elem, elemCanvas, parseTextContent} from './utils/dom'; +import {getNoxyUrl} from './utils/url'; import {getViewContent, getViewElem} from './view'; -// import {validatePow} from './events'; -import {parseContent} from './media'; +import {parseJSON} from './media'; +import {sortByCreatedAt} from './events'; -type Metadata = { - name?: string; +type Profile = { + name: string; about?: string; picture?: string; -}; + website?: string; +} -type Profile = { - metadata: { - [relay: string]: Metadata; +const transformMetadata = (data: unknown): Profile | undefined => { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + console.warn('expected nip-01 JSON object with user info, but got something funny', data); + return; + } + const hasNameString = 'name' in data && typeof data.name === 'string'; + const hasAboutString = 'about' in data && typeof data.about === 'string'; + const hasPictureString = 'picture' in data && typeof data.picture === 'string'; + // custom + const hasDisplayName = 'display_name' in data && typeof data.display_name === 'string'; + const hasWebsite = 'website' in data && typeof data.website === 'string'; + + if (!hasNameString && !hasAboutString && !hasPictureString && !hasDisplayName) { + console.warn('expected basic nip-01 user info (name, about, picture) but nothing found', data); + return; + } + const name = ( + hasNameString && data.name as string + || hasDisplayName && data.display_name as string + || '' + ); + return { + name, + ...(hasAboutString && {about: data.about as string}), + ...(hasPictureString && {picture: data.picture as string}), + ...(hasWebsite && {hasWebsite: data.website as string}) }; - name?: string; - picture?: string; - pubkey: string; }; -const userList: Array = []; -// const tempContactList = {}; +const profileMap: { + [pubkey: string]: Profile +} = {}; + +const metadataList: Array = []; -const setMetadata = ( - evt: Event, - relay: string, - metadata: Metadata, -) => { - let user = userList.find(u => u.pubkey === evt.pubkey); - if (!user) { - user = { - metadata: {[relay]: metadata}, - pubkey: evt.pubkey, - }; - userList.push(user); - } else { - user.metadata[relay] = { - ...user.metadata[relay], - // timestamp: evt.created_at, - ...metadata, - }; +export const handleMetadata = (evt: Event, relay: string) => { + if (metadataList.find(({id}) => id === evt.id)) { + return; } + const contactEventList = metadataList.filter(({pubkey}) => pubkey === evt.pubkey); + metadataList.push(evt); + contactEventList.push(evt); + contactEventList.sort(sortByCreatedAt); - // store the first seen name (for now) as main user.name - if (!user.name && metadata.name) { - user.name = metadata.name; + if (contactEventList.some(({created_at}) => created_at > evt.created_at) ) { + // evt is older + return; } + const content = parseJSON(evt.content); + const metadata = transformMetadata(content); + if (!metadata) { + return; + } + profileMap[evt.pubkey] = metadata; - // use the first seen profile pic (for now), pics from different relays are not supported yet - if (!user.picture && metadata.picture) { + if (metadata.picture) { const imgUrl = getNoxyUrl('data', metadata.picture, evt.id, relay); if (imgUrl) { - user.picture = imgUrl.href; - // update profile images that used some nip-13 work // if (imgUrl.href && validatePow(evt)) { // document.body @@ -62,118 +78,52 @@ const setMetadata = ( // } } } - - // update profile names - const name = user.metadata[relay].name || user.name || ''; - if (name) { + if (metadata.name) { + // update profile names document.body - // TODO: this should not depend on specific DOM structure, move pubkey info on username element - .querySelectorAll(`[data-pubkey="${evt.pubkey}"] > .mbox-body > header .mbox-username:not(.mbox-kind0-name)`) + .querySelectorAll(`[data-profile="${evt.pubkey}"]`) .forEach((username: HTMLElement) => { - username.textContent = name; + username.textContent = metadata.name; username.classList.add('mbox-kind0-name'); }); } - // if (tempContactList[relay]) { - // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); - // if (updates) { - // console.log('TODO: add contact list (kind 3)', updates); - // } - // } }; -export const handleMetadata = (evt: Event, relay: string) => { - const content = parseContent(evt.content); - if (!content || typeof content !== 'object' || Array.isArray(content)) { - console.warn('expected nip-01 JSON object with user info, but got something funny', evt); - return; - } - const hasNameString = 'name' in content && typeof content.name === 'string'; - const hasAboutString = 'about' in content && typeof content.about === 'string'; - const hasPictureString = 'picture' in content && typeof content.picture === 'string'; - // custom - const hasDisplayName = 'display_name' in content && typeof content.display_name === 'string'; - if (!hasNameString && !hasAboutString && !hasPictureString && !hasDisplayName) { - console.warn('expected basic nip-01 user info (name, about, picture) but nothing found', evt); - return; - } - const metadata: Metadata = { - ...(hasNameString && {name: content.name as string} || hasDisplayName && {name: content.display_name as string}), - ...(hasAboutString && {about: content.about as string}), - ...(hasPictureString && {picture: content.picture as string}), - }; - setMetadata(evt, relay, metadata); -}; - -export const getProfile = (pubkey: string) => userList.find(user => user.pubkey === pubkey); - -export const getMetadata = (evt: Event, relay: string) => { - const host = getHost(relay); - const user = getProfile(evt.pubkey); +export const getMetadata = (pubkey: string) => { + const user = profileMap[pubkey]; + const name = user?.name; + const userName = name || pubkey.slice(0, 8); // const userImg = user?.picture; - const name = user?.metadata[relay]?.name || user?.name; - const userName = name || evt.pubkey.slice(0, 8); - const userAbout = user?.metadata[relay]?.about || ''; const img = /* (userImg && validatePow(evt)) ? elem('img', { alt: `${userName} ${host}`, loading: 'lazy', src: userImg, title: `${userName} on ${host} ${userAbout}`, - }) : */ elemCanvas(evt.pubkey); - const time = new Date(evt.created_at * 1000); - return {host, img, name, time, userName}; + }) : */ elemCanvas(pubkey); + return {img, name, userName}; }; -/* export function handleContactList(evt, relay) { - if (getViewElem(evt.id)) { - return; - } - const art = renderUpdateContact(evt, relay); - if (textNoteList.length < 2) { - getViewContent().append(art); - return; - } - const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); - getViewElem(closestTextNotes[0].id).after(art); - setViewElem(evt.id, art); - // const user = userList.find(u => u.pupkey === evt.pubkey); - // if (user) { - // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); - // } else { - // tempContactList[relay] = tempContactList[relay] - // ? [...tempContactList[relay], evt] - // : [evt]; - // } -} */ - -// function renderUpdateContact(evt, relay) { -// const {img, time, userName} = getMetadata(evt, relay); -// const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ -// elem('header', {className: 'mbox-header'}, [ -// elem('small', {}, []), -// ]), -// elem('pre', {title: JSON.stringify(evt.content)}, [ -// elem('strong', {}, userName), -// ' updated contacts: ', -// JSON.stringify(evt.tags), -// ]), -// ]); -// return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); -// } - -export const renderProfile = (id: string) => { +export const renderProfile = (pubkey: string) => { const content = getViewContent(); - const header = getViewElem(id); - if (!content || !header) { + const header = getViewElem(pubkey); + const metadata = profileMap[pubkey]; + if (!content || !header || !metadata) { return; } - const profile = getProfile(id); - if (profile && profile.name) { + if (metadata.name) { const h1 = header.querySelector('h1'); if (h1) { - h1.textContent = profile.name; + h1.textContent = metadata.name; + document.title = metadata.name; } else { - header.append(elem('h1', {}, profile.name)); + header.append(elem('h1', {}, metadata.name)); + } + } + if (metadata.about) { + const detail = getViewElem(`detail-${pubkey}`); + if (!detail.children.length) { + const [content] = parseTextContent(metadata.about); + detail?.append(...content); } } -}; \ No newline at end of file +}; diff --git a/src/settings.ts b/src/settings.ts index 05edefe..a26504c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -228,7 +228,7 @@ profileForm.addEventListener('submit', async (e) => { return console.error(error, relay); } console.info(`publish request sent to ${relay}`); - profileStatus.textContent = 'profile metadata successfully published'; + profileStatus.textContent = 'profile successfully published'; profileStatus.hidden = false; profileSubmit.disabled = true; }); diff --git a/src/styles/view.css b/src/styles/view.css index 07cc562..b54e6fa 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -140,6 +140,10 @@ nav a { padding-left: var(--extra-space); } +.hero p { + padding-left: var(--extra-space); +} + .hero small { color: var(--color-accent); font-size: 1.1rem; @@ -150,9 +154,13 @@ nav a { text-overflow: ellipsis; white-space: nowrap; } -@media (min-width: 64ch) { +@media (min-width: 54ch) { .hero small { padding-left: var(--extra-space); text-align: left; } } + +.hero footer { + padding-left: var(--extra-space); +} diff --git a/src/ui.ts b/src/ui.ts index 9951ef0..f2489fa 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -9,6 +9,7 @@ import {openWriteInput} from './write'; // import {linkPreview} from './media'; import {getMetadata} from './profiles'; import {EventWithNip19, replyList} from './notes'; +import {parseJSON} from './media'; setInterval(() => { document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { @@ -20,7 +21,7 @@ export const createTextNote = ( evt: EventWithNip19, relay: string, ) => { - const {host, img, name, time, userName} = getMetadata(evt, relay); + const {img, name, userName} = getMetadata(evt.pubkey); const replies = replyList.filter(({replyTo}) => replyTo === evt.id) .sort(sortByCreatedAt) .reverse(); @@ -29,6 +30,7 @@ export const createTextNote = ( const reactions = getReactions(evt.id); const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); const [content, {firstLink}] = parseTextContent(evt.content); + const time = new Date(evt.created_at * 1000); const buttons = elem('div', {className: 'buttons'}, [ elem('button', {name: 'reply', type: 'button'}, [ elem('img', {height: 24, width: 24, src: '/assets/comment.svg'}) @@ -48,15 +50,19 @@ export const createTextNote = ( } const replyFeed: Array = replies[0] ? replies.map(e => setViewElem(e.id, createTextNote(e, relay))) : []; return elemArticle([ - elem('a', {className: 'mbox-img', href: `/${evt.nip19.npub}`}, img), + elem('a', {className: 'mbox-img', href: `/${evt.nip19.npub}`, tabIndex: -1}, img), elem('div', {className: 'mbox-body'}, [ elem('header', { className: 'mbox-header', - title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} + title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${relay}\n\nEvent-id: ${evt.id} ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.content}` }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), + elem('a', { + className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, + data: {profile: evt.pubkey}, + href: `/${evt.nip19.npub}`, + }, name || userName), ' ', elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), ]), @@ -67,11 +73,15 @@ export const createTextNote = ( buttons, ]), ...(replies[0] ? [elem('div', {className: 'mbox-replies'}, replyFeed)] : []), - ], {className: replies.length ? 'mbox-has-replies' : '', data: {id: evt.id, pubkey: evt.pubkey, relay}}); + ], { + className: replies.length ? 'mbox-has-replies' : '', + data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey, relay} + }); }; export const renderRecommendServer = (evt: Event, relay: string) => { - const {img, name, time, userName} = getMetadata(evt, relay); + const {img, userName} = getMetadata(evt.pubkey); + const time = new Date(evt.created_at * 1000); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ elem('header', {className: 'mbox-header'}, [ elem('small', {}, [ @@ -82,5 +92,8 @@ export const renderRecommendServer = (evt: Event, relay: string) => { ]); return elemArticle([ elem('div', {className: 'mbox-img'}, [img]), body - ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); + ], { + className: 'mbox-recommend-server', + data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey} + }); }; diff --git a/src/view.ts b/src/view.ts index 4b0b107..eb48d69 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,3 +1,4 @@ +import {nip19} from 'nostr-tools'; import {elem} from './utils/dom'; type ViewOptions = { @@ -49,19 +50,25 @@ export const setViewElem = (id: string, node: HTMLElement) => { const mainContainer = document.querySelector('main') as HTMLElement; -const createContainer = ( - route: string, - options: ViewOptions, -) => { +const renderView = (options: ViewOptions) => { const content = elem('div', {className: 'content'}); const dom: DOMMap = {}; switch (options.type) { case 'profile': - const header = elem('header', {className: 'hero'}, - elem('small', {}, route) - ); - dom[options.id] = header; + const pubkey = options.id; + const detail = elem('p', {data: {'profileDetails': pubkey}}); + dom[`detail-${pubkey}`] = detail; + const header = elem('header', {className: 'hero'}, [ + elem('small', {}, nip19.npubEncode(pubkey)), + elem('h1', {}, pubkey), + detail, + elem('footer', {}, [ + elem('span', {data:{following: pubkey}}) + ]) + ]); + dom[pubkey] = header; content.append(header); + document.title = pubkey; break; case 'note': break; @@ -69,6 +76,14 @@ const createContainer = ( break; } const view = elem('section', {className: 'view'}, [content]); + return {content, dom, view}; +}; + +const createContainer = ( + route: string, + options: ViewOptions, +) => { + const {content, dom, view} = renderView(options); const container = {id: route, options, view, content, dom}; mainContainer.append(view); containers.push(container);