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

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

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

@ -2,141 +2,110 @@
<html>
<head>
<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>
<link rel="stylesheet" href="main.css" type="text/css">
<link rel="manifest" href="/manifest.json">
</head>
<body>
<main class="tabbed">
<input type="radio" name="maintabs" id="settings" class="tab">
<label for="settings">profile</label>
<input type="radio" name="maintabs" id="feed" class="tab" checked>
<label for="feed">feed</label>
<!-- <input type="radio" name="maintabs" id="trending" class="tab">
<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"/>
<div class="root">
<main>
<aside>
<button name="new-note" id="bubble">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031">
<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"/>
</svg>
<div id="newMessage" hidden>
<form action="#" id="writeForm" class="form-inline">
<fieldset>
<legend>write a new note</legend>
<textarea name="message" rows="1"></textarea>
<div class="buttons">
<button type="submit" id="publish" disabled>send</button>
<button type="button" name="back">back</button>
</div>
<small id="sendstatus" class="form-status"></small>
</fieldset>
</button>
<section class="view" id="newNote" hidden>
<form action="#" id="writeForm" class="form-inline">
<fieldset>
<legend>write a new note</legend>
<textarea name="message" rows="1"></textarea>
<div class="buttons">
<button type="submit" id="publish" disabled>send</button>
<button type="button" name="back">back</button>
</div>
<small id="sendstatus" class="form-status"></small>
</fieldset>
</form>
</section>
<section class="view" id="settings" hidden>
<div class="content">
<form action="#" name="profile" autocomplete="new-password">
<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]">
<label for="profile_about">about</label>
<textarea name="about" id="profile_about"></textarea>
<label for="profile_picture">picture</label>
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
</div>
</form>
<form action="#" name="options">
<label for="filterDifficulty">
difficulty filter<br>
<small>
hide text notes with mining proof lower
than:&nbsp;<span data-display="filter_difficulty"></span>. a zero value shows all notes.
</small>
</label>
<input type="range" name="filter_difficulty" step="1" min="0" max="32" id="filterDifficulty" value="0">
<label class="number" for="miningTarget">
<span>
mining difficulty<br>
<small>
with which difficulty to try to mine a proof of work when publishing events, such as: notes, replies, reactions and profile updates.
use zero to disable mining.
difficulty is defined as the number of leading zero bits, read more about
<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noopener noreferrer">proof of work (nip-13)</a>.
</small>
</span>
<input type="number" name="mining_target" step="1" min="0" max="32" id="miningTarget" value="16">
</label>
<label class="number" for="miningTimeout">
<span>
mining timeout<br>
<small>abort trying to find a proof if timeout (in seconds) exceeds. use 0 to mine without a time limit.</small>
</span>
<input type="number" name="mining_timeout" step="1" min="0" max="256" id="miningTimeout" value="5">
</label>
</form>
<form action="#" name="settings" autocomplete="new-password">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="btn-inline" >
<small>show</small>
</button>
</label>
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
<p>
<a href="/about.html">about nostr.ch</a>
</p>
</footer>
</div>
</artcile>
<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 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">
<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]">
<label for="profile_about">about</label>
<textarea name="about" id="profile_about"></textarea>
<label for="profile_picture">picture</label>
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
</div>
</form>
<form action="#" name="options">
<label for="filterDifficulty">
difficulty filter<br>
<small>
hide text notes with mining proof lower
than:&nbsp;<span data-display="filter_difficulty"></span>. a zero value shows all notes.
</small>
</label>
<input type="range" name="filter_difficulty" step="1" min="0" max="32" id="filterDifficulty" value="0">
<label class="number" for="miningTarget">
<span>
mining difficulty<br>
<small>
with which difficulty to try to mine a proof of work when publishing events, such as: notes, replies, reactions and profile updates.
use zero to disable mining.
difficulty is defined as the number of leading zero bits, read more about
<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noopener noreferrer">proof of work (nip-13)</a>.
</small>
</span>
<input type="number" name="mining_target" step="1" min="0" max="32" id="miningTarget" value="16">
</label>
<label class="number" for="miningTimeout">
<span>
mining timeout<br>
<small>abort trying to find a proof if timeout (in seconds) exceeds. use 0 to mine without a time limit.</small>
</span>
<input type="number" name="mining_timeout" step="1" min="0" max="256" id="miningTimeout" value="5">
</label>
</form>
<form action="#" name="settings" autocomplete="new-password">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="btn-inline" >
<small>show</small>
</button>
</label>
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
<p>
<a href="/about.html">about nostr.ch</a>
</p>
</footer>
</div>
</div>
<div id="errorOverlay" class="form" hidden></div>
</main>
</section>
<section id="errorOverlay" class="form" hidden></section>
</aside>
<!-- views are inserted here -->
</main>
<nav>
<a data-nav href="/"><span>X</span>feed</a>
<button tpye="button" name="settings">settings</button>
</nav>
</div>
</body>
<script src="main.js"></script>

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

@ -52,39 +52,6 @@ const unSubAll = () => {
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,
@ -92,22 +59,20 @@ function sub24hFeed() {
kinds: [0, 1, 2, 7],
// until: Math.floor(Date.now() * 0.001),
since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)),
limit: 450,
limit: 50,
}
}));
}
function subNoteAndProfile(id) {
subProfile(id);
// view(`/${id}`); // assume text note
subTextNote(id);
subProfile(id);
}
function subTextNote(eventId) {
subList.push(pool.sub({
cb: (evt, relay) => {
clearTextNoteDetail();
showTextNoteDetail(evt, relay);
},
cb: onEvent,
filter: {
ids: [eventId],
kinds: [1],
@ -119,8 +84,9 @@ function subTextNote(eventId) {
function subProfile(pubkey) {
subList.push(pool.sub({
cb: (evt, relay) => {
renderProfile(evt, relay);
showProfileDetail();
console.log('found profile, unsub subTextNote somehow')
// renderProfile(evt, relay);
// view('/[profile]');
},
filter: {
authors: [pubkey],
@ -130,136 +96,57 @@ function subProfile(pubkey) {
}));
// get notes for profile
subList.push(pool.sub({
cb: (evt, relay) => {
showTextNoteDetail(evt, relay);
showProfileDetail();
},
cb: onEvent,
filter: {
authors: [pubkey],
kinds: [1],
limit: 450,
limit: 50,
}
}));
}
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;
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;
const containers = [
// {
// id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d',
// view: Node,
// content: Node,
// dom: {}
// }
});
];
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 eventRelayMap = {}; // eventId: [relay1, relay2]
@ -267,33 +154,33 @@ const eventRelayMap = {}; // eventId: [relay1, relay2]
const hasEventTag = tag => tag[0] === 'e';
const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention';
const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention';
const hasEnoughPOW = ([tag, , commitment]) => {
return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty;
};
const renderNote = (evt, i, sortedFeeds) => {
if (getViewElem(evt.id)) { // note already in view
return;
}
const article = createTextNote(evt, eventRelayMap[evt.id]);
if (i === 0) {
getViewContent().append(article);
} else {
getViewElem(sortedFeeds[i - 1].id).before(article);
}
setViewElem(evt.id, article);
};
const renderFeed = bounce(() => {
const now = Math.floor(Date.now() * 0.001);
const sortedFeeds = textNoteList
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;
});
})
.sort(sortByCreatedAt).reverse();
sortedFeeds.forEach((evt, i) => {
if (feedDomMap[evt.id]) {
// TODO check eventRelayMap if event was published to different relays
return;
}
const article = createTextNote(evt, eventRelayMap[evt.id]);
if (i === 0) {
feedContainer.append(article);
} else {
feedDomMap[sortedFeeds[i - 1].id].before(article);
}
feedDomMap[evt.id] = article;
});
}, 17); // (16.666 rounded, a bit arbitrary but that it doesnt update more than 60x per s)
.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) {
if (eventRelayMap[evt.id]) {
@ -305,29 +192,40 @@ function handleTextNote(evt, relay) {
} else {
textNoteList.push(evt);
}
}
if (!getViewElem(evt.id)) {
renderFeed();
}
}
const replyList = [];
const replyDomMap = {};
const replyToMap = {};
function handleReply(evt, relay) {
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
) {
return;
}
if (!replyToMap[evt.id]) {
replyToMap[evt.id] = getReplyTo(evt);
const replyTo = 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({
replyTo: replyToMap[evt.id],
...evt,
});
renderReply(evt, relay);
let replyContainer = parent.querySelector('.mobx-replies');
if (!replyContainer) {
replyContainer = elem('div', {className: 'mobx-replies'});
parent.append(replyContainer);
}
const reply = createTextNote(evt, relay);
replyContainer.append(reply);
setViewElem(evt.id, reply);
}
const reactionMap = {};
@ -353,22 +251,19 @@ function handleReaction(evt, relay) {
} else {
reactionMap[eventId] = [evt];
}
const article = feedDomMap[eventId] || replyDomMap[eventId];
const article = getViewElem(eventId);
if (article) {
const button = article.querySelector('button[name="star"]');
const reactions = button.querySelector('[data-reactions]');
reactions.textContent = reactionMap[eventId].length;
if (evt.pubkey === pubkey) {
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(' '));
}
}
}
// feed
const feedContainer = document.querySelector('#homefeed');
const feedDomMap = {};
const restoredReplyTo = localStorage.getItem('reply_to');
const sortByCreatedAt = (evt1, evt2) => {
@ -379,9 +274,9 @@ const sortByCreatedAt = (evt1, evt2) => {
};
function rerenderFeed() {
Object.keys(feedDomMap).forEach(key => delete feedDomMap[key]);
Object.keys(replyDomMap).forEach(key => delete replyDomMap[key]);
feedContainer.replaceChildren([]);
const domMap = getViewDom(); // TODO: this is only the current view, do this for all views
Object.keys(domMap).forEach(key => delete domMap[key]);
getViewContent().replaceChildren([]);
renderFeed();
}
@ -468,7 +363,7 @@ function createTextNote(evt, relay) {
// const content = isLongContent ? evt.content.slice(0, 280) : evt.content;
const hasReactions = reactionMap[evt.id]?.length > 0;
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 body = elem('div', {className: 'mbox-body'}, [
elem('header', {
@ -477,11 +372,9 @@ function createTextNote(evt, relay) {
${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''}
${evt.content}`
}, [
elem('small', {}, [
elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName),
' ',
elem('time', {dateTime: time.toISOString()}, formatTime(time)),
]),
elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.pubkey}`, data: {nav: '/[profile]'}}, name || userName),
' ',
elem('a', {href: `/${evt.id}`, data: {nav: '/[note]'}}, formatTime(time)),
]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content,
@ -489,13 +382,13 @@ function createTextNote(evt, relay) {
]),
elem('div', {className: 'buttons'}, [
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('img', {
alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`,
src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '),
}),
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}});
}
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) => (
{created_at: a},
{created_at: b},
@ -544,33 +421,33 @@ function isWssUrl(string) {
}
function handleRecommendServer(evt, relay) {
if (feedDomMap[evt.id] || !isWssUrl(evt.content)) {
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return;
}
const art = renderRecommendServer(evt, relay);
if (textNoteList.length < 2) {
feedContainer.append(art);
getViewContent().append(art);
} else {
const closestTextNotes = textNoteList
.filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty))
.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) {
if (feedDomMap[evt.id]) {
if (getViewElem(evt.id)) {
return;
}
const art = renderUpdateContact(evt, relay);
if (textNoteList.length < 2) {
feedContainer.append(art);
getViewContent().append(art);
return;
}
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
feedDomMap[closestTextNotes[0].id].after(art);
feedDomMap[evt.id] = art;
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);
@ -585,9 +462,7 @@ 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('small', {}, []),
]),
elem('pre', {title: JSON.stringify(evt.content)}, [
elem('strong', {}, userName),
@ -725,9 +600,8 @@ function getMetadata(evt, relay) {
src: userImg,
title: `${userName} on ${host} ${userAbout}`,
}) : elemCanvas(evt.pubkey);
const isReply = !!replyToMap[evt.id];
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 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;
const filterDifficultyInput = document.querySelector('#filterDifficulty');
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
['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 statusElem = article.querySelector('[data-reactions]');
reactionBtn.disabled = true;
@ -906,11 +756,11 @@ writeForm.addEventListener('submit', async (e) => {
publish.disabled = true;
if (replyTo) {
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({
kind: 1,
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
const settingsForm = document.querySelector('form[name="settings"]');
const privateKeyInput = settingsForm.querySelector('#privatekey');
@ -1172,6 +1255,7 @@ function powEvent(evt, options) {
worker.onerror = (err) => {
worker.terminate();
// promptError(msg.data.error, {});
cancelBtn.removeEventListener('click', onCancel);
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 {
bottom: 4rem;
background-color: darkmagenta;
border-color: darkmagenta;
border-radius: 10rem;
bottom: 8rem;
height: 10rem;
padding: 0;
position: fixed;
right: 5rem;
right: 8rem;
width: 10rem;
z-index: 12;
z-index: 1;
}
@media (orientation: portrait) {
#bubble {
bottom: calc(2 * var(--gap));
bottom: calc(4 * var(--gap));
right: var(--gap);
}
}
#bubble svg {
height: 100%;
position: relative;
width: 100%;
top: .5rem;
}
#newMessage {
#newNote {
align-items: center;
display: flex;
height: 100vh;
@ -25,12 +34,12 @@
z-index: 20;
}
@media (orientation: portrait) {
#newMessage {
#newNote {
align-items: start;
}
}
#newMessage #writeForm {
#newNote #writeForm {
align-items: start;
background-color: var(--bgcolor);
display: flex;
@ -46,11 +55,11 @@
padding: 2rem;
}
#newMessage .form-inline textarea {
#newNote .form-inline textarea {
flex-basis: 100%;
margin: var(--gap) 0;
}
#newMessage .buttons {
#newNote .buttons {
align-self: end;
}
Loading…
Cancel
Save