feed: add deeplinking and browser history
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details

Added deeplinks with browser history support. Each note and author
have now a detail view under nostr.ch/<event-id-or-pubkey> in a
future commit also /e/<event-id> and /p/<pubkey> 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
pull/45/head
OFF0 2 years ago
parent 23619bbaaa
commit cd99b5e5c1
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -49,11 +49,13 @@
} }
.mbox-body { .mbox-body {
flex-basis: calc(100% - 64px - 1rem);
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
word-break: break-word; word-break: break-word;
} }
.mbox-img + .mbox-body {
flex-basis: calc(100% - 64px - 1rem);
}
.mbox-header { .mbox-header {
flex-basis: calc(100% - 64px - 1rem); flex-basis: calc(100% - 64px - 1rem);
@ -64,6 +66,7 @@
.mbox-header time, .mbox-header time,
.mbox-username { .mbox-username {
color: var(--color-accent); color: var(--color-accent);
cursor: pointer;
} }
.mbox-kind0-name { .mbox-kind0-name {

@ -168,9 +168,9 @@ button:disabled {
.form-inline * + * { .form-inline * + * {
margin-left: var(--gap); margin-left: var(--gap);
} }
.cards .form-inline button, .form-inline button,
.cards .form-inline input[type="text"], .form-inline input[type="text"],
.cards .form-inline textarea { .form-inline textarea {
margin: .4rem 0; margin: .4rem 0;
} }

@ -41,6 +41,17 @@
</div> </div>
</artcile> </artcile>
<div class="cards" id="homefeed"></div> <div class="cards" id="homefeed"></div>
<div id="detail" hidden>
<article class="mbox" id="profile" data-pubkey>
<div class="mbox-body">
<img class="profile-image">
<h2 class="profile-name mbox-username"></h2>
<p class="profile-about"></p>
<dl><dt class="profile-pubkey-label" hidden>pubkey</dt><dd class="profile-pubkey"></dd></dl>
</div>
</article>
<section id="textnote"></section>
</div>
</div> </div>
<div class="tab-content"> <div class="tab-content">

@ -70,6 +70,8 @@ body {
margin: 0; margin: 0;
} }
h1, h2, h3, h4, h5 { font-weight: normal; }
body, body,
button, button,
input, input,

@ -2,15 +2,11 @@ import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tool
import {elem, parseTextContent} from './domutil.js'; import {elem, parseTextContent} from './domutil.js';
import {dateTime, formatTime} from './timeutil.js'; import {dateTime, formatTime} from './timeutil.js';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
const pool = relayPool(); const pool = relayPool();
pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); pool.addRelay('wss://relay.nostr.info', {read: true, write: true});
pool.addRelay('wss://relay.damus.io', {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://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) { function onEvent(evt, relay) {
switch (evt.kind) { switch (evt.kind) {
@ -41,22 +37,218 @@ let pubkey = localStorage.getItem('pub_key') || (() => {
return pubkey; return pubkey;
})(); })();
const subscription = pool.sub({ 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, cb: onEvent,
filter: { filter: {
// authors: [ kinds: [0, 1, 2, 7],
// '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark // until: Math.floor(Date.now() * 0.001),
// 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)),
// '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf limit: 100,
// '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // x1ddos }
// // pubkey, // me }));
// '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 }
// ],
// since: new Date(Date.now() - (24 * 60 * 60 * 1000)), function subNoteAndProfile(id) {
limit: 250, 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 const textNoteList = []; // could use indexDB
const eventRelayMap = {}; // eventId: [relay1, relay2] const eventRelayMap = {}; // eventId: [relay1, relay2]
const hasEventTag = tag => tag[0] === 'e'; const hasEventTag = tag => tag[0] === 'e';
@ -245,7 +437,7 @@ function createTextNote(evt, relay) {
${evt.content}` ${evt.content}`
}, [ }, [
elem('small', {}, [ 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)), elem('time', {dateTime: time.toISOString()}, formatTime(time)),
]), ]),
@ -276,10 +468,10 @@ function createTextNote(evt, relay) {
appendReplyForm(body.querySelector('button[name="reply"]')); appendReplyForm(body.querySelector('button[name="reply"]'));
requestAnimationFrame(() => updateElemHeight(writeInput)); requestAnimationFrame(() => updateElemHeight(writeInput));
} }
return rendernArticle([ return renderArticle([
elem('div', {className: 'mbox-img'}, [img]), body, elem('div', {className: 'mbox-img'}, [img]), body,
replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '',
]); ], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
} }
function handleReply(evt, relay) { function handleReply(evt, relay) {
@ -364,7 +556,7 @@ function renderUpdateContact(evt, relay) {
JSON.stringify(evt.tags), 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) { function renderRecommendServer(evt, relay) {
@ -377,12 +569,12 @@ function renderRecommendServer(evt, relay) {
]), ]),
` recommends server: ${evt.content}`, ` recommends server: ${evt.content}`,
]); ]);
return rendernArticle([ return renderArticle([
elem('div', {className: 'mbox-img'}, [img]), body 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'; const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox';
return elem('article', {...props, className}, content); return elem('article', {...props, className}, content);
} }
@ -390,16 +582,22 @@ function rendernArticle(content, props = {}) {
const userList = []; const userList = [];
// const tempContactList = {}; // const tempContactList = {};
function handleMetadata(evt, relay) { function parseContent(content) {
try { try {
const content = JSON.parse(evt.content); return JSON.parse(content);
setMetadata(evt, relay, content);
} catch(err) { } catch(err) {
console.log(evt); console.log(evt);
console.error(err); console.error(err);
} }
} }
function handleMetadata(evt, relay) {
const content = parseContent(evt.content);
if (content) {
setMetadata(evt, relay, content);
}
}
function setMetadata(evt, relay, content) { function setMetadata(evt, relay, content) {
let user = userList.find(u => u.pubkey === evt.pubkey); let user = userList.find(u => u.pubkey === evt.pubkey);
const picture = getNoxyUrl('data', content.picture, evt.id, relay).href; const picture = getNoxyUrl('data', content.picture, evt.id, relay).href;
@ -423,17 +621,16 @@ function setMetadata(evt, relay, content) {
} }
// update profile images // update profile images
if (user.picture) { if (user.picture) {
feedContainer document.body
.querySelectorAll(`canvas[data-pubkey="${evt.pubkey.slice(0, 12)}"]`) .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`)
.forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas)));
} }
if (user.metadata[relay].name) { if (user.metadata[relay].name) {
feedContainer document.body
.querySelectorAll(`.mbox-username[data-pubkey="${evt.pubkey.slice(0, 12)}"]`) .querySelectorAll(`[data-id="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`)
.forEach(username => { .forEach(username => {
username.textContent = user.metadata[relay].name; username.textContent = user.metadata[relay].name;
username.classList.add('mbox-kind0-name'); username.classList.add('mbox-kind0-name');
username.removeAttribute('data-pubkey');
}); });
} }
// if (tempContactList[relay]) { // if (tempContactList[relay]) {
@ -461,7 +658,7 @@ const getHost = (url) => {
} }
const elemCanvas = (text) => { 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 context = canvas.getContext('2d');
const color = `#${text.slice(0, 6)}`; const color = `#${text.slice(0, 6)}`;
context.fillStyle = color; context.fillStyle = color;
@ -472,7 +669,7 @@ const elemCanvas = (text) => {
if (color === '#000000') { if (color === '#000000') {
context.fillStyle = '#fff'; context.fillStyle = '#fff';
} }
context.fillText(text, 2, 46); context.fillText(text.slice(0, 8), 2, 46);
return canvas; return canvas;
} }
@ -495,23 +692,6 @@ function getMetadata(evt, relay) {
return {host, img, isReply, name, replies, time, userName}; 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 writeForm = document.querySelector('#writeForm');
const writeInput = document.querySelector('textarea[name="message"]'); const writeInput = document.querySelector('textarea[name="message"]');
@ -730,19 +910,6 @@ privateTgl.addEventListener('click', () => {
privateKeyInput.value = localStorage.getItem('private_key'); privateKeyInput.value = localStorage.getItem('private_key');
pubKeyInput.value = localStorage.getItem('pub_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 // profile
const profileForm = document.querySelector('form[name="profile"]'); const profileForm = document.querySelector('form[name="profile"]');
const profileSubmit = profileForm.querySelector('button[type="submit"]'); const profileSubmit = profileForm.querySelector('button[type="submit"]');

@ -73,7 +73,4 @@ input[type="radio"]:checked + label {
margin-left: var(--gap); margin-left: var(--gap);
order: 2; order: 2;
} }
.cards {
}
} }
Loading…
Cancel
Save