diff --git a/src/contacts.ts b/src/contacts.ts new file mode 100644 index 0000000..9491266 --- /dev/null +++ b/src/contacts.ts @@ -0,0 +1,96 @@ +import {Event, nip19} from 'nostr-tools'; +import {elem} from './utils/dom'; +import {dateTime} from './utils/time'; +import {isPTag, sortByCreatedAt} from './events'; +import {getViewContent} from './view'; +import {getMetadata} from './profiles'; + +const contactHistoryMap: { + [pubkey: string]: Event[] +} = {}; + +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); + } +}; + +export const setContactList = (evt: Event) => { + let contactHistory = contactHistoryMap[evt.pubkey]; + if (!contactHistory) { + contactHistoryMap[evt.pubkey] = [evt]; + updateFollowing(evt); + return; + } + if (contactHistory.find(({id}) => id === evt.id)) { + return; + } + contactHistory.push(evt); + contactHistory.sort(sortByCreatedAt); + updateFollowing(contactHistory[0]); +}; + +/** + * findChanges + * returns added and removed contacts list of P tags, ignores any tag other than 'p' + */ +const findChanges = (current: Event, previous: Event) => { + const previousContacts = previous.tags.join('\n'); // filter for p tags first? + const currentContacts = current.tags.join('\n'); + const addedContacts = current.tags.filter(([tag, pubkey]) => tag === 'p' && !previousContacts.includes(pubkey)); + const removedContacts = previous.tags.filter(([tag, pubkey]) => tag === 'p' && !currentContacts.includes(pubkey)); + 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 getContactUpdateMessage = ( + addedList: string[][], + removedList: string[][], +) => { + const content = []; + // console.log(addedContacts) + if (addedList.length && addedList[0]) { + const pubkey = addedList[0][1]; + const {userName} = getMetadata(pubkey); + const npub = nip19.npubEncode(pubkey); + content.push( + 'follows ', + elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName), + ); + } + if (addedList.length > 1) { + content.push(` (+ ${addedList.length - 1} others)`); + } + if (removedList?.length > 0) { + content.push(elem('small', {}, ` and unfollowed ${removedList.length}`)); + } + return content; +}; diff --git a/src/events.ts b/src/events.ts index 8ed8064..dc79271 100644 --- a/src/events.ts +++ b/src/events.ts @@ -2,6 +2,7 @@ import {Event} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; 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'; /** diff --git a/src/main.ts b/src/main.ts index b4d0b23..39c121d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,14 +4,15 @@ import {elem} from './utils/dom'; import {bounce} from './utils/time'; import {isWssUrl} from './utils/url'; import {closeSettingsView, config, toggleSettingsView} from './settings'; -import {sub24hFeed, subNote, subProfile} from './subscriptions' +import {sub24hFeed, subEventID, subNote, subProfile} from './subscriptions' import {getReplyTo, hasEventTag, 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 {getContactUpdateMessage, setContactList, updateContactList} from './contacts'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; -import {createTextNote, renderRecommendServer} from './ui'; +import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -149,6 +150,35 @@ config.rerenderFeed = () => { renderFeed(); }; +const handleContactList = (evt: Event, relay: string) => { + setContactList(evt); + const view = getViewOptions(); + if ( + getViewElem(evt.id) + || view.type !== 'profile' + || view.id !== evt.pubkey + ) { + 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; + } + const art = renderUpdateContact({...evt, content}, relay); + closestNote.after(art); + setViewElem(evt.id, art); +}; + const handleRecommendServer = (evt: Event, relay: string) => { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; @@ -160,12 +190,22 @@ const handleRecommendServer = (evt: Event, relay: string) => { const closestTextNotes = textNoteList // TODO: prob change to hasEnoughPOW .filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty)) + // use find instead of sort? .sort(sortEventCreatedAt(evt.created_at)); getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } setViewElem(evt.id, art); }; +const onEventDetails = (evt: Event, relay: string) => { + if (getViewElem(`detail-${evt.id}`)) { + return; + } + const art = renderEventDetails(evt, relay); + getViewContent().append(art); + setViewElem(`detail-${evt.id}`, art); +}; + const onEvent = (evt: Event, relay: string) => { switch (evt.kind) { case 0: @@ -178,7 +218,7 @@ const onEvent = (evt: Event, relay: string) => { handleRecommendServer(evt, relay); break; case 3: - // handleContactList(evt, relay); + handleContactList(evt, relay); break; case 7: handleReaction(evt, relay); @@ -211,6 +251,12 @@ const route = (path: string) => { console.warn(`type ${type} not yet supported`); } renderFeed(); + } 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) } }; @@ -284,12 +330,15 @@ const handleButton = (button: HTMLButtonElement) => { const handleContentClick = (content: HTMLElement) => { const card = content.closest('article[data-id]') as HTMLElement; if ( - !card || !card.dataset.id + !card + || !card.dataset.id + || !card.dataset.kind || getSelection()?.toString() // do not navigate if user selects text ) { return; } - const href = `/${nip19.noteEncode(card.dataset.id)}`; + const {kind, id} = card.dataset; + const href = `/${kind === '1' ? nip19.noteEncode(id) : id}`; route(href); history.pushState({}, '', href); }; @@ -310,8 +359,8 @@ document.body.addEventListener('click', (event: MouseEvent) => { handleButton(button); return; } - const content = target?.closest('.mbox-content'); - if (content) { - handleContentClick(content as HTMLElement); + const card = target?.closest('.mbox-body'); + if (card) { + handleContentClick(card as HTMLElement); } }); diff --git a/src/styles/cards.css b/src/styles/cards.css index 2e9ec6c..8924658 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -1,7 +1,7 @@ /* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */ .mbox { align-items: center; - border-bottom: 1px solid var(--bgcolor-nav); + border-top: 1px solid var(--bgcolor-nav); display: flex; flex-direction: row; flex-shrink: 0; @@ -49,11 +49,16 @@ a.mbox-img:focus { padding-bottom: var(--gap-half); word-break: break-word; } -.mbox-body div a { +.mbox-content a { text-decoration: underline; } .mbox-img + .mbox-body { - flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half)); + --max-width: calc(100% - var(--profileimg-size) - var(--gap-half)); + flex-basis: var(--max-width); + max-width: var(--max-width); +} +.mbox-replies .mbox-replies .mbox-img + .mbox-body { + --max-width: calc(100% - var(--profileimg-size) + var(--gap-half)); } .mbox-header { @@ -79,24 +84,28 @@ a.mbox-img:focus { .mbox-recommend-server .mbox-body { display: block; font-size: var(--font-small); - overflow: scroll; } .mbox-updated-contact .mbox-header, .mbox-recommend-server .mbox-header { display: inline; } +.mbox-content { + max-width: 100%; +} .mbox-updated-contact { - padding: 0 0 1rem 0; margin: 0; } +.mbox-updated-contact + .mbox-updated-contact { + border-top: none; +} .mbox { overflow: clip; } .mbox .mbox { - border-bottom: none; + border-top: none; max-width: 100%; overflow: visible; padding: 0; @@ -218,22 +227,20 @@ a.mbox-img:focus { position: relative; } -.mbox-replies .mbox .mbox .mbox-body { +/* +.mbox-replies .mbox-body.mbox-oneline { display: flex; flex-wrap: wrap; + font-size: var(--font-small); padding-bottom: var(--gap-half); padding-top: var(--gap-eight); } @media (orientation: portrait) { .mbox-replies .mbox .mbox .mbox-body { - font-size: var(--font-small); } } -.mbox-replies .mbox .mbox .mbox-header a:last-of-type::after { - content: " "; - display: inline-block; - padding-right: var(--gap-half); -} +*/ + .mbox-replies .mbox .mbox .buttons { display: none; } diff --git a/src/styles/main.css b/src/styles/main.css index 5cfbed5..2e6a9b4 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -87,7 +87,7 @@ body { color: var(--color); font-size: 1.6rem; line-height: 1.375; - word-break: break-all; + word-break: break-word; } @media (orientation: portrait) { body { @@ -158,3 +158,17 @@ pre { margin: 0; padding: .5rem 0; } + +dl { + display: grid; + grid-row-gap: var(--gap-half); + grid-template-columns: max-content auto; +} + +dt { + grid-column-start: 1; +} + +dd { + grid-column-start: 2; +} diff --git a/src/styles/view.css b/src/styles/view.css index b54e6fa..03c8a2f 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -113,6 +113,7 @@ nav button { } main .content { height: 1px; + padding-bottom: 10rem; } nav .content { display: flex; @@ -132,7 +133,6 @@ nav a { .hero { --extra-space: calc(var(--profileimg-size) + var(--gap-half)); - border-bottom: 1px solid var(--bgcolor-nav); padding: var(--gap-half); } diff --git a/src/subscriptions.ts b/src/subscriptions.ts index d8d6e6d..b70028b 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -189,4 +189,28 @@ export const subProfile = ( limit: 50, } }); + setTimeout(() => { + // get contacts + sub({ + cb: onEvent, + filter: { + authors: [pubkey], + kinds: [3], + limit: 3, + }, + }); + }, 100); +}; + +export const subEventID = ( + id: string, + onEvent: SubCallback, +) => { + sub({ + cb: onEvent, + filter: { + ids: [id], + limit: 1, + }, + }); }; diff --git a/src/ui.ts b/src/ui.ts index f2489fa..2e094c4 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,5 +1,5 @@ -import {Event} from 'nostr-tools'; -import {elem, elemArticle, parseTextContent} from './utils/dom'; +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'; @@ -79,6 +79,37 @@ export const createTextNote = ( }); }; +type EventWithContent = Omit & { + content: Children +} + +export const renderUpdateContact = ( + evt: EventWithContent, + relay: string, +) => { + const {img, name, userName} = getMetadata(evt.pubkey); + const time = new Date(evt.created_at * 1000); + return elemArticle([ + elem('div', {className: 'mbox-img'}, img), + elem('div', {className: 'mbox-body'}, [ + elem('header', {className: 'mbox-header'}, [ + elem('span', { + className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, + data: {profile: evt.pubkey}, + }, name || userName), + ' ', + elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content), + ]), + elem('div', {className: 'mbox-content'}, [ + ]), + ]), + ], { + className: 'mbox-updated-contact', + data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey, relay} + } + ); +}; + export const renderRecommendServer = (evt: Event, relay: string) => { const {img, userName} = getMetadata(evt.pubkey); const time = new Date(evt.created_at * 1000); @@ -97,3 +128,57 @@ export const renderRecommendServer = (evt: Event, relay: string) => { data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey} }); }; + +export const renderEventDetails = (evt: Event, relay: string) => { + const {img, name, userName} = getMetadata(evt.pubkey); + const time = new Date(evt.created_at * 1000); + const npub = nip19.npubEncode(evt.pubkey); + + let content = parseJSON(evt.content) + switch (typeof content) { + case 'object': + content = JSON.stringify(content, null, 2); + break; + default: + content = `${evt.content}`; + } + const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ + elem('header', {className: 'mbox-header'}, [ + elem('a', { + className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, + data: {profile: evt.pubkey}, + href: `/${npub}`, + }, name || userName), + ]), + elem('dl', {}, [ + elem('dt', {}, 'npub'), + elem('dd', {}, npub), + elem('dt', {}, 'created at'), + elem('dd', {}, dateTime.format(evt.created_at * 1000)), + elem('dt', {}, 'relay'), + elem('dd', {}, relay), + ]), + elem('h2', {}, 'Event'), + elem('dl', {}, [ + elem('dt', {}, 'id'), + elem('dd', {}, evt.id), + elem('dt', {}, 'kind'), + elem('dd', {}, evt.kind), + elem('dt', {}, 'pubkey'), + elem('dd', {}, evt.pubkey), + elem('dt', {}, 'tags count'), + elem('dd', {}, evt.tags.length), + elem('dt', {}, 'tags'), + elem('dd', {}, JSON.stringify(evt.tags)), + elem('dt', {}, 'content'), + elem('dd', {}, elem('pre', {}, content as string)), + ]), + ]); + return elemArticle([ + elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img), + body, + ], { + className: 'mbox-recommend-server', + data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey} + }); +}; diff --git a/src/utils/dom.ts b/src/utils/dom.ts index a39cc86..fcfd1b4 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -12,7 +12,7 @@ type DataAttributes = { type Attributes = Partial; -type Children = Array | HTMLElement | string | number | null; +export type Children = Array | HTMLElement | string | number | null; /** * example usage: @@ -29,7 +29,7 @@ type Children = Array | HTMLElement | string | numb export const elem = ( name: Extract, attrs?: Attributes, - children?: Children, + children?: Children | undefined, ): HTMLElementTagNameMap[Name] => { const el = document.createElement(name); if (attrs) { diff --git a/src/view.ts b/src/view.ts index eb48d69..043004a 100644 --- a/src/view.ts +++ b/src/view.ts @@ -9,6 +9,9 @@ type ViewOptions = { } | { type: 'profile'; id: string; +} | { + type: 'event'; + id: string; }; type DOMMap = { @@ -74,6 +77,15 @@ const renderView = (options: ViewOptions) => { break; case 'feed': break; + case 'event': + const id = options.id; + const eventHeader = elem('header', {className: 'hero'}, [ + elem('h1', {}, id), + ]); + dom[id] = eventHeader; + content.append(eventHeader); + document.title = id; + break; } const view = elem('section', {className: 'view'}, [content]); return {content, dom, view};