From cd99b5e5c185ab2c8efa1bf92a6be4c1efa2af73 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 13 Dec 2022 20:51:34 +0100 Subject: [PATCH] feed: add deeplinking and browser history Added deeplinks with browser history support. Each note and author have now a detail view under nostr.ch/ in a future commit also /e/ and /p/ could be supported. User can now navigate with browser back (with the expection of the settings overlay). Not everything is supported in the detail view (yet) i.e reply and stars are partially working (dont update visually), leaving this as open bug. This should fix itself once only 1 render container is used instead of different divs in the html for each view. Ideally the detail view should also query for related events, something to add in a future commit --- src/cards.css | 5 +- src/form.css | 6 +- src/index.html | 11 ++ src/main.css | 2 + src/main.js | 297 ++++++++++++++++++++++++++++++++++++++----------- src/tabs.css | 3 - 6 files changed, 252 insertions(+), 72 deletions(-) diff --git a/src/cards.css b/src/cards.css index 8e2dc4e..2f99bf4 100644 --- a/src/cards.css +++ b/src/cards.css @@ -49,11 +49,13 @@ } .mbox-body { - flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; word-break: break-word; } +.mbox-img + .mbox-body { + flex-basis: calc(100% - 64px - 1rem); +} .mbox-header { flex-basis: calc(100% - 64px - 1rem); @@ -64,6 +66,7 @@ .mbox-header time, .mbox-username { color: var(--color-accent); + cursor: pointer; } .mbox-kind0-name { diff --git a/src/form.css b/src/form.css index 4863d74..5a0151b 100644 --- a/src/form.css +++ b/src/form.css @@ -168,9 +168,9 @@ button:disabled { .form-inline * + * { margin-left: var(--gap); } -.cards .form-inline button, -.cards .form-inline input[type="text"], -.cards .form-inline textarea { +.form-inline button, +.form-inline input[type="text"], +.form-inline textarea { margin: .4rem 0; } diff --git a/src/index.html b/src/index.html index ca800a5..23f0f3e 100644 --- a/src/index.html +++ b/src/index.html @@ -41,6 +41,17 @@
+
diff --git a/src/main.css b/src/main.css index f10f73c..a60d2f1 100644 --- a/src/main.css +++ b/src/main.css @@ -70,6 +70,8 @@ body { margin: 0; } +h1, h2, h3, h4, h5 { font-weight: normal; } + body, button, input, diff --git a/src/main.js b/src/main.js index 7b423a1..fab7095 100644 --- a/src/main.js +++ b/src/main.js @@ -2,15 +2,11 @@ import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tool import {elem, parseTextContent} from './domutil.js'; import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ + const pool = relayPool(); pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); pool.addRelay('wss://relay.damus.io', {read: true, write: true}); -pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); pool.addRelay('wss://relay.nostr.ch', {read: true, write: true}); -// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -// read only -// pool.addRelay('wss://nostr.rocks', {read: true, write: false}); function onEvent(evt, relay) { switch (evt.kind) { @@ -41,20 +37,216 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); -const subscription = pool.sub({ - cb: onEvent, - filter: { - // authors: [ - // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark - // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc - // '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf - // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // x1ddos - // // pubkey, // me - // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 - // ], - // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), - limit: 250, +const subList = []; +const unSubAll = () => { + subList.forEach(sub => sub.unsub()); + subList.length = 0; +}; + +window.addEventListener('popstate', (event) => { + // console.log(`popstate path: ${location.pathname}, state: ${JSON.stringify(event.state)}`); + unSubAll(); + if (event.state?.author) { + subProfile(event.state.author); + return; } + if (event.state?.pubOrEvt) { + subNoteAndProfile(event.state.pubOrEvt); + return; + } + if (event.state?.eventId) { + subTextNote(event.state.eventId); + return; + } + sub24hFeed(); + showFeed(); +}); + +switch(location.pathname) { + case '/': + history.pushState({}, '', '/'); + sub24hFeed(); + break; + default: + const pubOrEvt = location.pathname.slice(1); + if (pubOrEvt.length === 64 && pubOrEvt.match(/^[0-9a-f]+$/)) { + history.pushState({pubOrEvt}, '', `/${pubOrEvt}`); + subNoteAndProfile(pubOrEvt); + } + break; +} + +function sub24hFeed() { + subList.push(pool.sub({ + cb: onEvent, + filter: { + kinds: [0, 1, 2, 7], + // until: Math.floor(Date.now() * 0.001), + since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), + limit: 100, + } + })); +} + +function subNoteAndProfile(id) { + subProfile(id); + subTextNote(id); +} + +function subTextNote(eventId) { + subList.push(pool.sub({ + cb: (evt, relay) => { + clearTextNoteDetail(); + showTextNoteDetail(evt, relay); + }, + filter: { + ids: [eventId], + kinds: [1], + limit: 1, + } + })); +} + +function subProfile(pubkey) { + subList.push(pool.sub({ + cb: (evt, relay) => { + renderProfile(evt, relay); + showProfileDetail(); + }, + filter: { + authors: [pubkey], + kinds: [0], + limit: 1, + } + })); + // get notes for profile + subList.push(pool.sub({ + cb: (evt, relay) => { + showTextNoteDetail(evt, relay); + showProfileDetail(); + }, + filter: { + authors: [pubkey], + kinds: [1], + limit: 150, + } + })); +} + +const detailContainer = document.querySelector('#detail'); +const profileContainer = document.querySelector('#profile'); +const profileAbout = profileContainer.querySelector('.profile-about'); +const profileName = profileContainer.querySelector('.profile-name'); +const profilePubkey = profileContainer.querySelector('.profile-pubkey'); +const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); +const profileImage = profileContainer.querySelector('.profile-image'); +const textNoteContainer = document.querySelector('#textnote'); + +function clearProfile() { + profileAbout.textContent = ''; + profileName.textContent = ''; + profilePubkey.textContent = ''; + profilePubkeyLabel.hidden = true; +} +function renderProfile(evt, relay) { + profileContainer.dataset.pubkey = evt.pubkey; + profilePubkey.textContent = evt.pubkey; + profilePubkeyLabel.hidden = false; + const content = parseContent(evt.content); + if (content) { + profileAbout.textContent = content.about; + profileName.textContent = content.name; + const noxyImg = getNoxyUrl('data', content.picture, evt.id, relay); + if (noxyImg) { + profileImage.setAttribute('src', getNoxyUrl('data', noxyImg, evt.id, relay)) + } + } +} + +function showProfileDetail() { + profileContainer.hidden = false; + textNoteContainer.hidden = false; + showDetail(); +} + +function clearTextNoteDetail() { + textNoteContainer.replaceChildren([]); +} + +function showTextNoteDetail(evt, relay) { + if (!textNoteContainer.querySelector(`[data-id="${evt.id}"]`)) { + textNoteContainer.append(createTextNote(evt, relay)); + } + textNoteContainer.hidden = false; + profileContainer.hidden = true; + showDetail(); +} + +function showDetail() { + feedContainer.hidden = true; + detailContainer.hidden = false; +} + +function showFeed() { + feedContainer.hidden = false; + detailContainer.hidden = true; +} + +document.querySelector('label[for="feed"]').addEventListener('click', () => { + if (location.pathname !== '/') { + showFeed(); + history.pushState({}, '', '/'); + unSubAll(); + sub24hFeed(); + } +}); + +document.body.addEventListener('click', (e) => { + const button = e.target.closest('button'); + const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey; + const id = e.target.closest('[data-id]')?.dataset.id; + const relay = e.target.closest('[data-relay]')?.dataset.relay; + if (button && button.name === 'reply') { + if (localStorage.getItem('reply_to') === id) { + writeInput.blur(); + return; + } + appendReplyForm(button); + localStorage.setItem('reply_to', id); + return; + } + if (button && button.name === 'star') { + upvote(id, relay) + return; + } + if (button && button.name === 'back') { + hideNewMessage(true); + return; + } + const username = e.target.closest('.mbox-username') + if (username) { + history.pushState({author: pubkey}, '', `/${pubkey}`); + unSubAll(); + clearProfile(); + clearTextNoteDetail(); + subProfile(pubkey); + showProfileDetail(); + return; + } + const eventTime = e.target.closest('.mbox-header time'); + if (eventTime) { + history.pushState({eventId: id, relay}, '', `/${id}`); + unSubAll(); + clearTextNoteDetail(); + subTextNote(id); + return; + } + // const container = e.target.closest('[data-append]'); + // if (container) { + // container.append(...parseTextContent(container.dataset.append)); + // delete container.dataset.append; + // return; + // } }); const textNoteList = []; // could use indexDB @@ -245,7 +437,7 @@ function createTextNote(evt, relay) { ${evt.content}` }, [ elem('small', {}, [ - elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, data: {pubkey: evt.pubkey.slice(0, 12)}}, name || userName), + elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName), ' ', elem('time', {dateTime: time.toISOString()}, formatTime(time)), ]), @@ -276,10 +468,10 @@ function createTextNote(evt, relay) { appendReplyForm(body.querySelector('button[name="reply"]')); requestAnimationFrame(() => updateElemHeight(writeInput)); } - return rendernArticle([ + return renderArticle([ elem('div', {className: 'mbox-img'}, [img]), body, replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', - ]); + ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } function handleReply(evt, relay) { @@ -364,7 +556,7 @@ function renderUpdateContact(evt, relay) { JSON.stringify(evt.tags), ]), ]); - return rendernArticle([img, body], {className: 'mbox-updated-contact'}); + return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); } function renderRecommendServer(evt, relay) { @@ -377,12 +569,12 @@ function renderRecommendServer(evt, relay) { ]), ` recommends server: ${evt.content}`, ]); - return rendernArticle([ + return renderArticle([ elem('div', {className: 'mbox-img'}, [img]), body - ], {className: 'mbox-recommend-server', data: {relay: evt.content}}); + ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); } -function rendernArticle(content, props = {}) { +function renderArticle(content, props = {}) { const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox'; return elem('article', {...props, className}, content); } @@ -390,16 +582,22 @@ function rendernArticle(content, props = {}) { const userList = []; // const tempContactList = {}; -function handleMetadata(evt, relay) { +function parseContent(content) { try { - const content = JSON.parse(evt.content); - setMetadata(evt, relay, content); + return JSON.parse(content); } catch(err) { console.log(evt); console.error(err); } } +function handleMetadata(evt, relay) { + const content = parseContent(evt.content); + if (content) { + setMetadata(evt, relay, content); + } +} + function setMetadata(evt, relay, content) { let user = userList.find(u => u.pubkey === evt.pubkey); const picture = getNoxyUrl('data', content.picture, evt.id, relay).href; @@ -423,17 +621,16 @@ function setMetadata(evt, relay, content) { } // update profile images if (user.picture) { - feedContainer - .querySelectorAll(`canvas[data-pubkey="${evt.pubkey.slice(0, 12)}"]`) + document.body + .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); } if (user.metadata[relay].name) { - feedContainer - .querySelectorAll(`.mbox-username[data-pubkey="${evt.pubkey.slice(0, 12)}"]`) + document.body + .querySelectorAll(`[data-id="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) .forEach(username => { username.textContent = user.metadata[relay].name; username.classList.add('mbox-kind0-name'); - username.removeAttribute('data-pubkey'); }); } // if (tempContactList[relay]) { @@ -461,7 +658,7 @@ const getHost = (url) => { } const elemCanvas = (text) => { - const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text.slice(0, 12)}}); + const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}}); const context = canvas.getContext('2d'); const color = `#${text.slice(0, 6)}`; context.fillStyle = color; @@ -472,7 +669,7 @@ const elemCanvas = (text) => { if (color === '#000000') { context.fillStyle = '#fff'; } - context.fillText(text, 2, 46); + context.fillText(text.slice(0, 8), 2, 46); return canvas; } @@ -495,23 +692,6 @@ function getMetadata(evt, relay) { return {host, img, isReply, name, replies, time, userName}; } -feedContainer.addEventListener('click', (e) => { - const button = e.target.closest('button'); - if (button && button.name === 'reply') { - if (localStorage.getItem('reply_to') === button.dataset.eventId) { - writeInput.blur(); - return; - } - appendReplyForm(button); - localStorage.setItem('reply_to', button.dataset.eventId); - return; - } - if (button && button.name === 'star') { - upvote(button.dataset.eventId, button.dataset.relay) - return; - } -}); - const writeForm = document.querySelector('#writeForm'); const writeInput = document.querySelector('textarea[name="message"]'); @@ -730,19 +910,6 @@ privateTgl.addEventListener('click', () => { privateKeyInput.value = localStorage.getItem('private_key'); pubKeyInput.value = localStorage.getItem('pub_key'); -document.body.addEventListener('click', (e) => { - // const container = e.target.closest('[data-append]'); - // if (container) { - // container.append(...parseTextContent(container.dataset.append)); - // delete container.dataset.append; - // return; - // } - const back = e.target.closest('[name="back"]') - if (back) { - hideNewMessage(true); - } -}); - // profile const profileForm = document.querySelector('form[name="profile"]'); const profileSubmit = profileForm.querySelector('button[type="submit"]'); diff --git a/src/tabs.css b/src/tabs.css index da7bbec..57a0c07 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -73,7 +73,4 @@ input[type="radio"]:checked + label { margin-left: var(--gap); order: 2; } - .cards { - - } } \ No newline at end of file