From ad81d892e115130cf8beb4bd5fcc8c8accc53b22 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Thu, 2 Feb 2023 09:07:59 +0100 Subject: [PATCH] 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). --- src/cards.css | 22 +- src/error.css | 2 +- src/form.css | 15 +- src/index.html | 223 ++++++++--------- src/main.css | 9 +- src/main.js | 636 ++++++++++++++++++++++++++++--------------------- src/tabs.css | 76 ------ src/view.css | 110 +++++++++ src/write.css | 27 ++- 9 files changed, 614 insertions(+), 506 deletions(-) delete mode 100644 src/tabs.css create mode 100644 src/view.css diff --git a/src/cards.css b/src/cards.css index 006ee5a..b50fbce 100644 --- a/src/cards.css +++ b/src/cards.css @@ -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; diff --git a/src/error.css b/src/error.css index d3cd89a..bd70ad6 100644 --- a/src/error.css +++ b/src/error.css @@ -29,7 +29,7 @@ } #errorOverlay .buttons { - max-width: var(--max-width); + max-width: var(--content-width); } @media (orientation: portrait) { #errorOverlay .buttons { diff --git a/src/form.css b/src/form.css index 200476e..370e027 100644 --- a/src/form.css +++ b/src/form.css @@ -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; } diff --git a/src/index.html b/src/index.html index 333f00c..4027258 100644 --- a/src/index.html +++ b/src/index.html @@ -2,141 +2,110 @@ - + + + nostr -
- - - - - - -
-
- - - +
+
+
- - + + +
- - - - - - - -
- -
- - - - - - -
- - -
-
-
- - - - -
-
- - - - -
- - - -
-
- -
-
- -
+ + + + + + + diff --git a/src/main.css b/src/main.css index 4d78b6f..66bf78b 100644 --- a/src/main.css +++ b/src/main.css @@ -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; } diff --git a/src/main.js b/src/main.js index fcce2e9..9833525 100644 --- a/src/main.js +++ b/src/main.js @@ -6,12 +6,18 @@ import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ const pool = relayPool(); -pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -pool.addRelay('wss://relay.damus.io', {read: true, write: true}); -pool.addRelay('wss://relay.snort.social', {read: true, write: true}); -pool.addRelay('wss://eden.nostr.land', {read: true, write: true}); -pool.addRelay('wss://relay.nostr.ch', {read: true, write: true}); +// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); +// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://relay.damus.io', {read: true, write: true}); +// pool.addRelay('wss://relay.snort.social', {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://eden.nostr.land', {read: true, write: true}); +// pool.addRelay('wss://nostr.einundzwanzig.space', {read: true, write: true}); +// pool.addRelay('wss://relay.nostrich.de', {read: true, write: true}); +// pool.addRelay('wss://nostr.cercatrova.me', {read: true, write: true}); +pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); function onEvent(evt, relay) { switch (evt.kind) { @@ -48,39 +54,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, @@ -88,22 +61,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], @@ -115,8 +86,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], @@ -126,136 +98,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] @@ -263,33 +156,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]) { @@ -301,29 +194,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 = {}; @@ -349,22 +253,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) => { @@ -375,9 +276,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(); } @@ -464,7 +365,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', { @@ -473,11 +374,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, @@ -485,13 +384,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 : ''), @@ -508,22 +407,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}, @@ -540,33 +423,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); @@ -581,9 +464,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), @@ -721,9 +602,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}; } /** @@ -786,30 +666,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"]'); @@ -847,7 +703,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; @@ -902,11 +758,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, @@ -950,6 +806,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'); @@ -1168,6 +1257,7 @@ function powEvent(evt, options) { worker.onerror = (err) => { worker.terminate(); + // promptError(msg.data.error, {}); cancelBtn.removeEventListener('click', onCancel); reject(err); }; diff --git a/src/tabs.css b/src/tabs.css deleted file mode 100644 index 9938ecc..0000000 --- a/src/tabs.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/src/view.css b/src/view.css new file mode 100644 index 0000000..beefa1f --- /dev/null +++ b/src/view.css @@ -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; +} diff --git a/src/write.css b/src/write.css index d185f56..ab46ef4 100644 --- a/src/write.css +++ b/src/write.css @@ -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; } \ No newline at end of file