routes: use generic view containers

proof of concept to use generic view containers instead of specifc
functions to show and hide particular views.

a view has an identifier (path) which is used to subscribe to
relevant data. changing a view updates the history so that browser
back displays the last view. each view container has its own
scrollbar so that the scrolling position should be preserved when
changing back and forth between different views.

this change also removes CSS tabs in favor of view or overlays
such settings or write a new text note.

profile and notes deeplink use now native HTML anchors to improve
accessibility (copy/paste, open-in-new-tab, search engines).
pull/72/head
OFF0 2 years ago
parent 784b9d9ea0
commit 57be701ef9
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -6,14 +6,11 @@
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0 var(--gap); max-width: var(--content-width);
}
@media (orientation: portrait) {
.mbox {
padding: 0 var(--gap-half); padding: 0 var(--gap-half);
}
} }
.mbox:last-child { .mbox:last-child {
margin-bottom: 0; margin-bottom: 0;
@ -28,10 +25,10 @@
border-radius: var(--profileimg-size); border-radius: var(--profileimg-size);
flex-basis: var(--profileimg-size); flex-basis: var(--profileimg-size);
height: var(--profileimg-size); height: var(--profileimg-size);
margin-right: 1.5rem; margin-right: var(--gap-half);
max-height: var(--profileimg-size); max-height: var(--profileimg-size);
max-width: var(--profileimg-size); max-width: var(--profileimg-size);
overflow: hidden; overflow: clip;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
@ -53,15 +50,18 @@
word-break: break-word; word-break: break-word;
} }
.mbox-img + .mbox-body { .mbox-img + .mbox-body {
flex-basis: calc(100% - 64px - 1rem); flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half));
} }
.mbox-header { .mbox-header {
flex-basis: calc(100% - 64px - 1rem); flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
margin-top: 0; margin-top: 0;
} }
.mbox-header a {
font-size: var(--font-small);
}
.mbox-header time, .mbox-header time,
.mbox-username { .mbox-username {
color: var(--color-accent); color: var(--color-accent);
@ -90,7 +90,7 @@
} }
.mbox { .mbox {
overflow: hidden; overflow: clip;
} }
.mbox .mbox { .mbox .mbox {
overflow: visible; overflow: visible;

@ -29,7 +29,7 @@
} }
#errorOverlay .buttons { #errorOverlay .buttons {
max-width: var(--max-width); max-width: var(--content-width);
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#errorOverlay .buttons { #errorOverlay .buttons {

@ -8,7 +8,8 @@ form,
--padding: 1.2rem; --padding: 1.2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap); max-width: var(--content-width);
padding: 0 var(--gap);
} }
fieldset { fieldset {
@ -21,7 +22,7 @@ legend {
display: none; display: none;
width: 100%; width: 100%;
} }
#newMessage legend { #newNote legend {
display: block; display: block;
} }
@ -82,17 +83,17 @@ textarea {
textarea:focus { textarea:focus {
min-height: 3.5rem; min-height: 3.5rem;
} }
#newMessage textarea { #newNote textarea {
min-height: 10rem; min-height: 10rem;
} }
#newMessage textarea:focus { #newNote textarea:focus {
min-height: 18rem; min-height: 18rem;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#newMessage textarea { #newNote textarea {
min-height: 8rem; min-height: 8rem;
} }
#newMessage textarea:focus { #newNote textarea:focus {
min-height: 15rem; min-height: 15rem;
} }
} }
@ -235,7 +236,7 @@ button#publish {
button[name="back"] { button[name="back"] {
display: none; display: none;
} }
#newMessage button[name="back"] { #newNote button[name="back"] {
align-self: end; align-self: end;
display: inherit; display: inherit;
} }

@ -2,31 +2,23 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>nostr</title> <title>nostr</title>
<link rel="stylesheet" href="main.css" type="text/css"> <link rel="stylesheet" href="main.css" type="text/css">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<main class="tabbed"> <div class="root">
<input type="radio" name="maintabs" id="settings" class="tab"> <main>
<label for="settings">profile</label> <aside>
<input type="radio" name="maintabs" id="feed" class="tab" checked> <button name="new-note" id="bubble">
<label for="feed">feed</label> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031">
<!-- <input type="radio" name="maintabs" id="trending" class="tab"> <path fill="var(--bgcolor-textinput)" stroke="darkmagenta" stroke-width="4" d="M2.463 31.824q0-4.789 1.893-9.248 1.892-4.46 5.361-8.087 3.47-3.626 8.07-6.333 4.598-2.707 10.324-4.2 5.727-1.493 11.836-1.493 6.107 0 11.834 1.492 5.725 1.494 10.325 4.2 4.599 2.708 8.07 6.334 3.47 3.627 5.362 8.087 1.891 4.46 1.891 9.248 0 5.97-2.967 11.384-2.967 5.414-7.982 9.336-5.015 3.922-11.957 6.248-6.94 2.325-14.576 2.325-7.463 0-14.334-2.221l-8.537 6.038q-4.789 3.02-6.733 1.752-1.943-1.266-.867-7.13l1.77-8.886q-4.2-3.887-6.49-8.71-2.29-4.825-2.29-10.136Z"/>
<label for="trending">trending</label>
<input type="radio" name="maintabs" id="direct" class="tab">
<label for="direct">direct</label>
<input type="radio" name="maintabs" id="chat" class="tab">
<label for="chat">chat</label> -->
<div class="tabs">
<div class="tab-content">
<artcile>
<svg id="bubble" xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031">
<path fill="var(--bgcolor-textinput)" stroke="currentColor" stroke-width="2" d="M2.463 31.824q0-4.789 1.893-9.248 1.892-4.46 5.361-8.087 3.47-3.626 8.07-6.333 4.598-2.707 10.324-4.2 5.727-1.493 11.836-1.493 6.107 0 11.834 1.492 5.725 1.494 10.325 4.2 4.599 2.708 8.07 6.334 3.47 3.627 5.362 8.087 1.891 4.46 1.891 9.248 0 5.97-2.967 11.384-2.967 5.414-7.982 9.336-5.015 3.922-11.957 6.248-6.94 2.325-14.576 2.325-7.463 0-14.334-2.221l-8.537 6.038q-4.789 3.02-6.733 1.752-1.943-1.266-.867-7.13l1.77-8.886q-4.2-3.887-6.49-8.71-2.29-4.825-2.29-10.136Z"/>
</svg> </svg>
<div id="newMessage" hidden> </button>
<section class="view" id="newNote" hidden>
<form action="#" id="writeForm" class="form-inline"> <form action="#" id="writeForm" class="form-inline">
<fieldset> <fieldset>
<legend>write a new note</legend> <legend>write a new note</legend>
@ -38,39 +30,9 @@
<small id="sendstatus" class="form-status"></small> <small id="sendstatus" class="form-status"></small>
</fieldset> </fieldset>
</form> </form>
</div> </section>
</artcile> <section class="view" id="settings" hidden>
<div class="cards" id="homefeed"></div> <div class="content">
<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 class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/12.md">NIP-12 (generic queries)</a></p>
</div>
<div class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/04.md">NIP-04 (direct msg)</a></p>
</div>
<div class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/28.md">NIP-28 (public chat)</a></p>
</div>
<div class="tab-content">
<!-- <div class="form form-inline">
<input type="text" name="username" id="username" placeholder="username">
<button type="button" name="publish-username" tabindex="0">publish</button>
</div> -->
<form action="#" name="profile" autocomplete="new-password"> <form action="#" name="profile" autocomplete="new-password">
<label for="profile_name">name</label> <label for="profile_name">name</label>
<input type="text" name="name" id="profile_name" autocomplete="off" pattern="[a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9]"> <input type="text" name="name" id="profile_name" autocomplete="off" pattern="[a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9]">
@ -134,9 +96,16 @@
</p> </p>
</footer> </footer>
</div> </div>
</div> </section>
<div id="errorOverlay" class="form" hidden></div> <section id="errorOverlay" class="form" hidden></section>
</aside>
<!-- views are inserted here -->
</main> </main>
<nav>
<a data-nav href="/"><span>X</span>feed</a>
<button tpye="button" name="settings">settings</button>
</nav>
</div>
</body> </body>
<script src="main.js"></script> <script src="main.js"></script>

@ -1,4 +1,4 @@
@import "tabs.css"; @import "view.css";
@import "cards.css"; @import "cards.css";
@import "form.css"; @import "form.css";
@import "write.css"; @import "write.css";
@ -16,7 +16,7 @@
--font-small: 1.2rem; --font-small: 1.2rem;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem; --gap-half: 1.2rem;
--max-width: 96ch; --content-width: min(100% - 2.4rem, 96ch);
} }
::selection { ::selection {
@ -74,6 +74,11 @@ body {
color: var(--color); color: var(--color);
font-size: 1.6rem; font-size: 1.6rem;
line-height: 1.5; line-height: 1.5;
}
html, body {
min-height: 100%;
height: 100%;
margin: 0; margin: 0;
} }

@ -52,39 +52,6 @@ const unSubAll = () => {
subList.length = 0; 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() { function sub24hFeed() {
subList.push(pool.sub({ subList.push(pool.sub({
cb: onEvent, cb: onEvent,
@ -92,22 +59,20 @@ function sub24hFeed() {
kinds: [0, 1, 2, 7], kinds: [0, 1, 2, 7],
// until: Math.floor(Date.now() * 0.001), // until: Math.floor(Date.now() * 0.001),
since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)),
limit: 450, limit: 50,
} }
})); }));
} }
function subNoteAndProfile(id) { function subNoteAndProfile(id) {
subProfile(id); // view(`/${id}`); // assume text note
subTextNote(id); subTextNote(id);
subProfile(id);
} }
function subTextNote(eventId) { function subTextNote(eventId) {
subList.push(pool.sub({ subList.push(pool.sub({
cb: (evt, relay) => { cb: onEvent,
clearTextNoteDetail();
showTextNoteDetail(evt, relay);
},
filter: { filter: {
ids: [eventId], ids: [eventId],
kinds: [1], kinds: [1],
@ -119,8 +84,9 @@ function subTextNote(eventId) {
function subProfile(pubkey) { function subProfile(pubkey) {
subList.push(pool.sub({ subList.push(pool.sub({
cb: (evt, relay) => { cb: (evt, relay) => {
renderProfile(evt, relay); console.log('found profile, unsub subTextNote somehow')
showProfileDetail(); // renderProfile(evt, relay);
// view('/[profile]');
}, },
filter: { filter: {
authors: [pubkey], authors: [pubkey],
@ -130,136 +96,57 @@ function subProfile(pubkey) {
})); }));
// get notes for profile // get notes for profile
subList.push(pool.sub({ subList.push(pool.sub({
cb: (evt, relay) => { cb: onEvent,
showTextNoteDetail(evt, relay);
showProfileDetail();
},
filter: { filter: {
authors: [pubkey], authors: [pubkey],
kinds: [1], kinds: [1],
limit: 450, limit: 50,
} }
})); }));
} }
const detailContainer = document.querySelector('#detail'); const containers = [
const profileContainer = document.querySelector('#profile'); // {
const profileAbout = profileContainer.querySelector('.profile-about'); // id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d',
const profileName = profileContainer.querySelector('.profile-name'); // view: Node,
const profilePubkey = profileContainer.querySelector('.profile-pubkey'); // content: Node,
const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); // dom: {}
const profileImage = profileContainer.querySelector('.profile-image');
const textNoteContainer = document.querySelector('#textnote');
function clearProfile() {
profileAbout.textContent = '';
profileName.textContent = '';
profilePubkey.textContent = '';
profilePubkeyLabel.hidden = true;
profileImage.removeAttribute('src');
profileImage.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 = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay);
if (noxyImg) {
profileImage.setAttribute('src', noxyImg);
profileImage.hidden = false;
}
}
}
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.closest('.buttons'));
localStorage.setItem('reply_to', id);
return;
}
if (button && button.name === 'star') {
upvote(id, pubkey);
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;
// } // }
}); ];
let activeContainerIndex = null;
// 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;
// profileImage.removeAttribute('src');
// profileImage.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 = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay);
// if (noxyImg) {
// profileImage.setAttribute('src', noxyImg);
// profileImage.hidden = false;
// }
// }
// }
const textNoteList = []; // could use indexDB const textNoteList = []; // could use indexDB
const eventRelayMap = {}; // eventId: [relay1, relay2] const eventRelayMap = {}; // eventId: [relay1, relay2]
@ -267,33 +154,33 @@ const eventRelayMap = {}; // eventId: [relay1, relay2]
const hasEventTag = tag => tag[0] === 'e'; const hasEventTag = tag => tag[0] === 'e';
const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention'; const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention';
const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention'; const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention';
const hasEnoughPOW = ([tag, , commitment]) => {
const renderFeed = bounce(() => {
const now = Math.floor(Date.now() * 0.001);
const sortedFeeds = textNoteList
// dont render notes from the future
.filter(note => note.created_at <= now)
// if difficulty filter is configured dont render notes with too little pow
.filter(note => {
return !fitlerDifficulty || note.tags.some(([tag, , commitment]) => {
return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty;
}); };
}) const renderNote = (evt, i, sortedFeeds) => {
.sort(sortByCreatedAt).reverse(); if (getViewElem(evt.id)) { // note already in view
sortedFeeds.forEach((evt, i) => {
if (feedDomMap[evt.id]) {
// TODO check eventRelayMap if event was published to different relays
return; return;
} }
const article = createTextNote(evt, eventRelayMap[evt.id]); const article = createTextNote(evt, eventRelayMap[evt.id]);
if (i === 0) { if (i === 0) {
feedContainer.append(article); getViewContent().append(article);
} else { } else {
feedDomMap[sortedFeeds[i - 1].id].before(article); getViewElem(sortedFeeds[i - 1].id).before(article);
} }
feedDomMap[evt.id] = article; setViewElem(evt.id, article);
}); };
}, 17); // (16.666 rounded, a bit arbitrary but that it doesnt update more than 60x per s)
const renderFeed = bounce(() => {
const now = Math.floor(Date.now() * 0.001);
textNoteList
// dont render notes from the future
.filter(note => note.created_at <= now)
// if difficulty filter is configured dont render notes with too little pow
.filter(note => !fitlerDifficulty || note.tags.some(hasEnoughPOW))
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
function handleTextNote(evt, relay) { function handleTextNote(evt, relay) {
if (eventRelayMap[evt.id]) { if (eventRelayMap[evt.id]) {
@ -305,29 +192,40 @@ function handleTextNote(evt, relay) {
} else { } else {
textNoteList.push(evt); textNoteList.push(evt);
} }
}
if (!getViewElem(evt.id)) {
renderFeed(); renderFeed();
} }
} }
const replyList = []; const replyList = [];
const replyDomMap = {};
const replyToMap = {};
function handleReply(evt, relay) { function handleReply(evt, relay) {
if ( if (
replyDomMap[evt.id] // already rendered probably received from another relay getViewElem(evt.id) // already rendered probably received from another relay
|| evt.tags.some(isMention) // ignore mentions for now || evt.tags.some(isMention) // ignore mentions for now
) { ) {
return; return;
} }
if (!replyToMap[evt.id]) { const replyTo = getReplyTo(evt);
replyToMap[evt.id] = getReplyTo(evt); const evtWithReplyTo = {replyTo, ...evt};
replyList.push(evtWithReplyTo);
renderReply(evtWithReplyTo, relay);
}
function renderReply(evt, relay) {
const parent = getViewElem(evt.replyTo);
if (!parent) { // root article has not been rendered
return;
} }
replyList.push({ let replyContainer = parent.querySelector('.mobx-replies');
replyTo: replyToMap[evt.id], if (!replyContainer) {
...evt, replyContainer = elem('div', {className: 'mobx-replies'});
}); parent.append(replyContainer);
renderReply(evt, relay); }
const reply = createTextNote(evt, relay);
replyContainer.append(reply);
setViewElem(evt.id, reply);
} }
const reactionMap = {}; const reactionMap = {};
@ -353,22 +251,19 @@ function handleReaction(evt, relay) {
} else { } else {
reactionMap[eventId] = [evt]; reactionMap[eventId] = [evt];
} }
const article = feedDomMap[eventId] || replyDomMap[eventId]; const article = getViewElem(eventId);
if (article) { if (article) {
const button = article.querySelector('button[name="star"]'); const button = article.querySelector('button[name="star"]');
const reactions = button.querySelector('[data-reactions]'); const reactions = button.querySelector('[data-reactions]');
reactions.textContent = reactionMap[eventId].length; reactions.textContent = reactionMap[eventId].length;
if (evt.pubkey === pubkey) { if (evt.pubkey === pubkey) {
const star = button.querySelector('img[src*="star"]'); const star = button.querySelector('img[src*="star"]');
star?.setAttribute('src', 'assets/star-fill.svg'); star?.setAttribute('src', '/assets/star-fill.svg');
star?.setAttribute('title', getReactionList(eventId).join(' ')); star?.setAttribute('title', getReactionList(eventId).join(' '));
} }
} }
} }
// feed
const feedContainer = document.querySelector('#homefeed');
const feedDomMap = {};
const restoredReplyTo = localStorage.getItem('reply_to'); const restoredReplyTo = localStorage.getItem('reply_to');
const sortByCreatedAt = (evt1, evt2) => { const sortByCreatedAt = (evt1, evt2) => {
@ -379,9 +274,9 @@ const sortByCreatedAt = (evt1, evt2) => {
}; };
function rerenderFeed() { function rerenderFeed() {
Object.keys(feedDomMap).forEach(key => delete feedDomMap[key]); const domMap = getViewDom(); // TODO: this is only the current view, do this for all views
Object.keys(replyDomMap).forEach(key => delete replyDomMap[key]); Object.keys(domMap).forEach(key => delete domMap[key]);
feedContainer.replaceChildren([]); getViewContent().replaceChildren([]);
renderFeed(); renderFeed();
} }
@ -468,7 +363,7 @@ function createTextNote(evt, relay) {
// const content = isLongContent ? evt.content.slice(0, 280) : evt.content; // const content = isLongContent ? evt.content.slice(0, 280) : evt.content;
const hasReactions = reactionMap[evt.id]?.length > 0; const hasReactions = reactionMap[evt.id]?.length > 0;
const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey); const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey);
const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => replyDomMap[e.id] = createTextNote(e, relay)) : []; const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : [];
const [content, {firstLink}] = parseTextContent(evt.content); const [content, {firstLink}] = parseTextContent(evt.content);
const body = elem('div', {className: 'mbox-body'}, [ const body = elem('div', {className: 'mbox-body'}, [
elem('header', { elem('header', {
@ -477,11 +372,9 @@ function createTextNote(evt, relay) {
${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''}
${evt.content}` ${evt.content}`
}, [ }, [
elem('small', {}, [ elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.pubkey}`, data: {nav: '/[profile]'}}, name || userName),
elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName),
' ', ' ',
elem('time', {dateTime: time.toISOString()}, formatTime(time)), elem('a', {href: `/${evt.id}`, data: {nav: '/[note]'}}, formatTime(time)),
]),
]), ]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content, ...content,
@ -489,13 +382,13 @@ function createTextNote(evt, relay) {
]), ]),
elem('div', {className: 'buttons'}, [ elem('div', {className: 'buttons'}, [
elem('button', {name: 'reply', type: 'button'}, [ elem('button', {name: 'reply', type: 'button'}, [
elem('img', {height: 24, width: 24, src: 'assets/comment.svg'}) elem('img', {height: 24, width: 24, src: '/assets/comment.svg'})
]), ]),
elem('button', {name: 'star', type: 'button'}, [ elem('button', {name: 'star', type: 'button'}, [
elem('img', { elem('img', {
alt: didReact ? '✭' : '✩', // ♥ alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24, height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '), title: getReactionList(evt.id).join(' '),
}), }),
elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''),
@ -512,22 +405,6 @@ function createTextNote(evt, relay) {
], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); ], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
} }
function renderReply(evt, relay) {
const replyToId = replyToMap[evt.id];
const article = feedDomMap[replyToId] || replyDomMap[replyToId];
if (!article) { // root article has not been rendered
return;
}
let replyContainer = article.querySelector('.mobx-replies');
if (!replyContainer) {
replyContainer = elem('div', {className: 'mobx-replies'});
article.append(replyContainer);
}
const reply = createTextNote(evt, relay);
replyContainer.append(reply);
replyDomMap[evt.id] = reply;
}
const sortEventCreatedAt = (created_at) => ( const sortEventCreatedAt = (created_at) => (
{created_at: a}, {created_at: a},
{created_at: b}, {created_at: b},
@ -544,33 +421,33 @@ function isWssUrl(string) {
} }
function handleRecommendServer(evt, relay) { function handleRecommendServer(evt, relay) {
if (feedDomMap[evt.id] || !isWssUrl(evt.content)) { if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return; return;
} }
const art = renderRecommendServer(evt, relay); const art = renderRecommendServer(evt, relay);
if (textNoteList.length < 2) { if (textNoteList.length < 2) {
feedContainer.append(art); getViewContent().append(art);
} else { } else {
const closestTextNotes = textNoteList const closestTextNotes = textNoteList
.filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty))
.sort(sortEventCreatedAt(evt.created_at)); .sort(sortEventCreatedAt(evt.created_at));
feedDomMap[closestTextNotes[0].id]?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
} }
feedDomMap[evt.id] = art; setViewElem(evt.id, art);
} }
function handleContactList(evt, relay) { function handleContactList(evt, relay) {
if (feedDomMap[evt.id]) { if (getViewElem(evt.id)) {
return; return;
} }
const art = renderUpdateContact(evt, relay); const art = renderUpdateContact(evt, relay);
if (textNoteList.length < 2) { if (textNoteList.length < 2) {
feedContainer.append(art); getViewContent().append(art);
return; return;
} }
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
feedDomMap[closestTextNotes[0].id].after(art); getViewElem(closestTextNotes[0].id).after(art);
feedDomMap[evt.id] = art; setViewElem(evt.id, art);
// const user = userList.find(u => u.pupkey === evt.pubkey); // const user = userList.find(u => u.pupkey === evt.pubkey);
// if (user) { // if (user) {
// console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags);
@ -585,9 +462,7 @@ function renderUpdateContact(evt, relay) {
const {img, time, userName} = getMetadata(evt, relay); const {img, time, userName} = getMetadata(evt, relay);
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [ elem('header', {className: 'mbox-header'}, [
elem('small', {}, [ elem('small', {}, []),
]),
]), ]),
elem('pre', {title: JSON.stringify(evt.content)}, [ elem('pre', {title: JSON.stringify(evt.content)}, [
elem('strong', {}, userName), elem('strong', {}, userName),
@ -725,9 +600,8 @@ function getMetadata(evt, relay) {
src: userImg, src: userImg,
title: `${userName} on ${host} ${userAbout}`, title: `${userName} on ${host} ${userAbout}`,
}) : elemCanvas(evt.pubkey); }) : elemCanvas(evt.pubkey);
const isReply = !!replyToMap[evt.id];
const time = new Date(evt.created_at * 1000); const time = new Date(evt.created_at * 1000);
return {host, img, isReply, name, time, userName}; return {host, img, name, time, userName};
} }
/** /**
@ -790,30 +664,6 @@ function appendReplyForm(el) {
const lockScroll = () => document.body.style.overflow = 'hidden'; const lockScroll = () => document.body.style.overflow = 'hidden';
const unlockScroll = () => document.body.style.removeProperty('overflow'); const unlockScroll = () => document.body.style.removeProperty('overflow');
const newMessageDiv = document.querySelector('#newMessage');
document.querySelector('#bubble').addEventListener('click', (e) => {
localStorage.removeItem('reply_to'); // should it forget old replyto context?
newMessageDiv.prepend(writeForm);
hideNewMessage(false);
writeInput.focus();
if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height');
}
lockScroll();
requestAnimationFrame(() => updateElemHeight(writeInput));
});
document.body.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
hideNewMessage(true);
}
});
function hideNewMessage(hide) {
unlockScroll();
newMessageDiv.hidden = hide;
}
let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0; let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0;
const filterDifficultyInput = document.querySelector('#filterDifficulty'); const filterDifficultyInput = document.querySelector('#filterDifficulty');
const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]'); const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]');
@ -851,7 +701,7 @@ async function upvote(eventId, eventPubkey) {
.map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields
['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25)
]; ];
const article = (feedDomMap[eventId] || replyDomMap[eventId]); const article = getViewElem(eventId);
const reactionBtn = article.querySelector('[name="star"]'); const reactionBtn = article.querySelector('[name="star"]');
const statusElem = article.querySelector('[data-reactions]'); const statusElem = article.querySelector('[data-reactions]');
reactionBtn.disabled = true; reactionBtn.disabled = true;
@ -906,11 +756,11 @@ writeForm.addEventListener('submit', async (e) => {
publish.disabled = true; publish.disabled = true;
if (replyTo) { if (replyTo) {
localStorage.removeItem('reply_to'); localStorage.removeItem('reply_to');
newMessageDiv.append(writeForm); publishView.append(writeForm);
} }
hideNewMessage(true); publishView.hidden = true;
}; };
const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0]
const newEvent = await powEvent({ const newEvent = await powEvent({
kind: 1, kind: 1,
content, content,
@ -954,6 +804,239 @@ function updateElemHeight(el) {
} }
} }
function getViewContent() {
return containers[activeContainerIndex]?.content;
}
function getViewDom() {
return containers[activeContainerIndex]?.dom;
}
function getViewElem(key) {
return containers[activeContainerIndex]?.dom[key];
}
function setViewElem(key, node) {
const container = containers[activeContainerIndex];
if (container) {
container.dom[key] = node;
}
return node;
}
const mainContainer = document.querySelector('main');
const getContainer = (containers, route) => {
let container = containers.find(c => c.route === route);
if (container) {
return container;
}
const content = elem('div', {className: 'content'});
const view = elem('section', {className: 'view'}, [content]);
mainContainer.append(view);
container = {route, view, content, dom: {}};
containers.push(container);
return container;
};
document.body.onload = () => console.log('------------ pageload ------------')
function view(route) {
const active = containers[activeContainerIndex];
active?.view.classList.remove('view-active');
const nextContainer = getContainer(containers, route);
const nextContainerIndex = containers.indexOf(nextContainer);
if (nextContainerIndex === activeContainerIndex) {
return;
}
if (active) {
nextContainer.view.classList.add('view-next');
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
nextContainer.view.classList.remove('view-next', 'view-prev');
nextContainer.view.classList.add('view-active');
});
// // console.log(activeContainerIndex, nextContainerIndex);
getViewContent()?.querySelectorAll('.view-prev').forEach(prev => {
prev.classList.remove('view-prev');
prev.classList.add('view-next');
});
active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev');
activeContainerIndex = nextContainerIndex;
});
}
function navigate(route) {
if (typeof route === 'string') {
view(route);
history.pushState({}, '', route);
return;
}
if (route.pubkey) {
view(`/${route.pubkey}`);
history.pushState(route, '', `/${route.pubkey}`);
return;
}
if (route.note) {
view(`/${route.note}`);
history.pushState(route, '', `/${route.note}`);
return;
}
if (route.pubOrNote) {
view(`/${route.pubOrNote}`);
history.pushState(route, '', `/${route.pubOrNote}`);
return;
}
console.warn('unhandeleded', route);
}
// onload
switch(location.pathname) {
case '/':
sub24hFeed();
navigate('/');
break;
default:
const pubOrNote = location.pathname.slice(1);
if (pubOrNote.length === 64 && pubOrNote.match(/^[0-9a-f]+$/)) {
navigate({pubOrNote});
subNoteAndProfile(pubOrNote);
}
break;
}
window.addEventListener('popstate', (event) => {
// console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`);
unSubAll();
if (event.state?.pubkey) {
subProfile(event.state.pubkey);
view(`/${event.state.pubkey}`);
return;
}
if (event.state?.pubOrNote) {
subNoteAndProfile(event.state.pubOrNote);
view(`/${event.state.pubOrNote}`); // assuming note
return;
}
if (event.state?.note) {
subTextNote(event.state.note);
view(`/${event.state.note}`); // assuming note
return;
}
if (location.pathname === '/') {
sub24hFeed();
view('/');
return;
}
});
const settingsView = document.querySelector('#settings');
const publishView = document.querySelector('#newNote');
document.body.addEventListener('click', (e) => {
const a = e.target.closest('a');
const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey;
const id = e.target.closest('[data-id]')?.dataset.id;
if (a) {
if ('nav' in a.dataset) {
e.preventDefault();
if (!settingsView.hidden) {
settingsView.hidden = true;
}
if (!publishView.hidden) {
publishView.hidden = true;
}
const href = a.getAttribute('href');
switch(href) {
case '/':
navigate('/');
unSubAll();
sub24hFeed();
break;
default:
switch(a.dataset.nav) {
case '/[profile]':
unSubAll();
subProfile(pubkey);
navigate({pubkey});
break;
case '/[note]':
unSubAll();
subTextNote(id)
navigate({note: id});
break;
default:
console.warn('what route is that', href);
}
break;
}
e.preventDefault();
}
return;
}
const button = e.target.closest('button');
if (button) {
switch(button.name) {
case 'reply':
if (localStorage.getItem('reply_to') === id) {
writeInput.blur();
return;
}
appendReplyForm(button.closest('.buttons'));
localStorage.setItem('reply_to', id);
break;
case 'star':
upvote(id, pubkey);
break;
case 'settings':
settingsView.hidden = !settingsView.hidden;
break;
case 'new-note':
if (publishView.hidden) {
localStorage.removeItem('reply_to'); // should it forget old replyto context?
publishView.append(writeForm);
if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height');
}
requestAnimationFrame(() => {
updateElemHeight(writeInput);
writeInput.focus();
});
publishView.removeAttribute('hidden');
} else {
publishView.hidden = true;
}
break;
case 'back':
publishView.hidden = true;
break;
}
}
// const container = e.target.closest('[data-append]');
// if (container) {
// container.append(...parseTextContent(container.dataset.append));
// delete container.dataset.append;
// return;
// }
});
// document.body.addEventListener('keyup', (e) => {
// if (e.key === 'Escape') {
// hideNewMessage(true);
// }
// });
// settings // settings
const settingsForm = document.querySelector('form[name="settings"]'); const settingsForm = document.querySelector('form[name="settings"]');
const privateKeyInput = settingsForm.querySelector('#privatekey'); const privateKeyInput = settingsForm.querySelector('#privatekey');
@ -1172,6 +1255,7 @@ function powEvent(evt, options) {
worker.onerror = (err) => { worker.onerror = (err) => {
worker.terminate(); worker.terminate();
// promptError(msg.data.error, {});
cancelBtn.removeEventListener('click', onCancel); cancelBtn.removeEventListener('click', onCancel);
reject(err); reject(err);
}; };

@ -1,76 +0,0 @@
.tabs {
flex-basis: 100%;
margin-top: 4rem;
}
.tabs .tab-content { display: none; }
#feed:checked ~ .tabs .tab-content:nth-child(1),
#trending:checked ~ .tabs .tab-content:nth-child(2),
#direct:checked ~ .tabs .tab-content:nth-child(3),
#chat:checked ~ .tabs .tab-content:nth-child(4),
#settings:checked ~ .tabs .tab-content:nth-child(5) { display: block; }
input[type="radio"].tab {
clip: rect(0, 0, 0, 0);
height: 0;
overflow: hidden;
position: absolute;
width: 0;
}
.tab + label {
background-color: var(--bgcolor-textinput);
border: none;
color: var(--color);
display: inline-block;
margin-left: var(--gap);
margin-top: var(--gap);
outline: 2px solid var(--bgcolor-accent);
padding: 1rem 1.5em;
position: relative;
top: 1px;
z-index: 11;
}
input[type="radio"]:checked + label {
background: var(--bgcolor-accent);
}
.tab:focus + label,
.tab:active + label {
border-color: var(--focus-border-color);
border-radius: var(--focus-border-radius);
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
.tab-content {
max-width: var(--max-width);
min-height: 200px;
padding: var(--gap-half) 0 100px 0;
}
.tabbed {
align-items: start;
display: flex;
flex-wrap: wrap;
}
@media (orientation: portrait) {
.tabbed {
align-items: start;
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
justify-content: start;
}
.tabs {
height: 100vh;
height: 100dvh;
margin-top: 0;
order: 1;
overflow: scroll;
width: 100vw;
}
.tab + label {
margin-top: calc(-3 * var(--gap));
margin-left: var(--gap);
order: 2;
}
}

@ -0,0 +1,110 @@
.root {
display: flex;
height: 100%;
max-height: 100%;
flex-direction: column;
}
@media (orientation: landscape) {
.root {
flex-direction: row-reverse;
}
}
main {
display: flex;
flex-grow: 1;
height: 100%;
overflow: clip;
position: relative;
width: 100%;
}
aside {
order: 2;
}
nav {
background-color: indigo;
display: flex;
flex-direction: row;
flex-grow: 1;
flex-shrink: 0;
justify-content: space-around;
overflow-y: auto;
padding: 1rem 1.5rem;
user-select: none;
-webkit-user-select: none;
}
@supports (padding: max(0px)) {
nav {
padding-bottom: env(safe-area-inset-bottom);
}
}
@media (orientation: landscape) {
nav {
flex-direction: column;
justify-content: space-between;
}
}
.view {
background-color: var(--bgcolor);
display: flex;
flex-direction: column;
left: 0;
min-height: 100%;
opacity: 1;
overflow-x: clip;
position: absolute;
top: 0;
transform: translateX(0);
transition: transform .3s cubic-bezier(.465,.183,.153,.946);
width: 100%;
will-change: transform;
}
@media (orientation: landscape) {
.view {
transition: opacity .3s cubic-bezier(.465,.183,.153,.946);
}
}
@media (orientation: portrait) {
.view.view-next {
transform: translateX(100%);
}
.view.view-prev {
position: relative;
transform: translateX(-20%);
z-index: 0;
}
}
@media (orientation: landscape) {
.view.view-next,
.view.view-next {
opacity: 0;
pointer-events: none;
}
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-inline: auto;
overflow-y: auto;
padding: var(--gap-half) 0;
width: 100%;
}
main .content {
height: 1px;
}
nav .content {
display: flex;
flex-direction: row;
justify-content: space-between;
}
nav a {
display: flex;
flex-direction: column;
text-align: center;
text-decoration: none;
}

@ -1,20 +1,29 @@
#bubble { #bubble {
bottom: 4rem; background-color: darkmagenta;
border-color: darkmagenta;
border-radius: 10rem;
bottom: 8rem;
height: 10rem; height: 10rem;
padding: 0; padding: 0;
position: fixed; position: fixed;
right: 5rem; right: 8rem;
width: 10rem; width: 10rem;
z-index: 12; z-index: 1;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#bubble { #bubble {
bottom: calc(2 * var(--gap)); bottom: calc(4 * var(--gap));
right: var(--gap); right: var(--gap);
} }
} }
#bubble svg {
height: 100%;
position: relative;
width: 100%;
top: .5rem;
}
#newMessage { #newNote {
align-items: center; align-items: center;
display: flex; display: flex;
height: 100vh; height: 100vh;
@ -25,12 +34,12 @@
z-index: 20; z-index: 20;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#newMessage { #newNote {
align-items: start; align-items: start;
} }
} }
#newMessage #writeForm { #newNote #writeForm {
align-items: start; align-items: start;
background-color: var(--bgcolor); background-color: var(--bgcolor);
display: flex; display: flex;
@ -46,11 +55,11 @@
padding: 2rem; padding: 2rem;
} }
#newMessage .form-inline textarea { #newNote .form-inline textarea {
flex-basis: 100%; flex-basis: 100%;
margin: var(--gap) 0; margin: var(--gap) 0;
} }
#newMessage .buttons { #newNote .buttons {
align-self: end; align-self: end;
} }
Loading…
Cancel
Save