refactor: improve view and move code to ui and notes
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details

cleanup code and move parts to ui.ts and notes.ts.

simplify view and fix some weird animation issue, it should run
pretty stable now.

updated color and spacings.

profile view now showing kind 0 name, but it is unnecessarily
re-rendering. this part should probably go to a custom profil
subscription callback in the future. keeping as is for now and
refactor later.
OFF0 2 years ago
parent 174e6e43d9
commit 5be04fa2d3
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -1,88 +1,25 @@
import {Event, nip19} from 'nostr-tools'; import {Event, nip19} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto'; import {zeroLeadingBitsCount} from './utils/crypto';
import {elem, elemArticle, parseTextContent} from './utils/dom'; import {elem} from './utils/dom';
import {bounce, dateTime, formatTime} from './utils/time'; import {bounce} from './utils/time';
import {isWssUrl} from './utils/url'; import {isWssUrl} from './utils/url';
import {sub24hFeed, subNote, subProfile} from './subscriptions' import {sub24hFeed, subNote, subProfile} from './subscriptions'
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
import {closeSettingsView, config, toggleSettingsView} from './settings'; import {closeSettingsView, config, toggleSettingsView} from './settings';
import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write'; import {closePublishView, openWriteInput, togglePublishView} from './write';
import {linkPreview} from './media'; import {handleMetadata, renderProfile} from './profiles';
import {getMetadata, handleMetadata} from './profiles'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderRecommendServer} from './ui';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
type EventWithNip19 = Event & {
nip19: {
note: string;
npub: string;
}
};
const textNoteList: Array<EventWithNip19> = []; // could use indexDB
type EventRelayMap = { type EventRelayMap = {
[eventId: string]: string[]; [eventId: string]: string[];
}; };
const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2] const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2]
type EventWithNip19AndReplyTo = EventWithNip19 & {
replyTo: string;
};
const replyList: Array<EventWithNip19AndReplyTo> = [];
const createTextNote = (evt: EventWithNip19, relay: string) => {
const {host, img, name, time, userName} = getMetadata(evt, relay);
const replies = replyList.filter(({replyTo}) => replyTo === evt.id);
// const isLongContent = evt.content.trimRight().length > 280;
// const content = isLongContent ? evt.content.slice(0, 280) : evt.content;
const reactions = getReactions(evt.id);
const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey);
const replyFeed: Array<HTMLElement> = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : [];
const [content, {firstLink}] = parseTextContent(evt.content);
const buttons = elem('div', {className: 'buttons'}, [
elem('button', {name: 'reply', type: 'button'}, [
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`,
title: getReactionContents(evt.id).join(' '),
}),
elem('small', {data: {reactions: ''}}, reactions.length || ''),
]),
]);
const body = elem('div', {className: 'mbox-body'}, [
elem('header', {
className: 'mbox-header',
title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id}
${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''}
${evt.content}`
}, [
elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName),
' ',
elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))),
]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content,
(firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null,
]),
buttons,
]);
if (localStorage.getItem('reply_to') === evt.id) {
openWriteInput(buttons, evt.id);
}
return elemArticle([
elem('div', {className: 'mbox-img'}, img),
body,
...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []),
], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
};
const renderNote = ( const renderNote = (
evt: EventWithNip19, evt: EventWithNip19,
i: number, i: number,
@ -108,18 +45,46 @@ const hasEnoughPOW = (
}; };
const renderFeed = bounce(() => { const renderFeed = bounce(() => {
const view = getViewOptions();
switch (view.type) {
case 'note':
textNoteList
.concat(replyList)
.filter(note => note.id === view.id)
.forEach(renderNote);
break;
case 'profile':
const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
[
...textNoteList
.filter(note => note.pubkey === view.id),
...replyList.filter(reply => reply.pubkey === view.id)
.map(reply => textNoteList.find(note => note.id === reply.replyTo) || replyList.find(note => note.id === reply.replyTo) )
.filter(isEvent)
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote); // render in-reply-to
renderProfile(view.id);
break;
case 'feed':
const now = Math.floor(Date.now() * 0.001); const now = Math.floor(Date.now() * 0.001);
textNoteList textNoteList
.filter(note => {
// dont render notes from the future // dont render notes from the future
.filter(note => note.created_at <= now) if (note.created_at > now) return false;
// if difficulty filter is configured dont render notes with too little pow // if difficulty filter is configured dont render notes with too little pow
.filter(note => !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))
})
.sort(sortByCreatedAt) .sort(sortByCreatedAt)
.reverse() .reverse()
.forEach(renderNote); .forEach(renderNote);
break;
}
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) }, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => { const renderReply = (evt: EventWithNip19AndReplyTo) => {
const parent = getViewElem(evt.replyTo); const parent = getViewElem(evt.replyTo);
if (!parent) { // root article has not been rendered if (!parent) { // root article has not been rendered
return; return;
@ -129,7 +94,7 @@ const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => {
replyContainer = elem('div', {className: 'mobx-replies'}); replyContainer = elem('div', {className: 'mobx-replies'});
parent.append(replyContainer); parent.append(replyContainer);
} }
const reply = createTextNote(evt, relay); const reply = createTextNote(evt, eventRelayMap[evt.id][0]);
replyContainer.append(reply); replyContainer.append(reply);
setViewElem(evt.id, reply); setViewElem(evt.id, reply);
}; };
@ -148,7 +113,7 @@ const handleReply = (evt: EventWithNip19, relay: string) => {
} }
const evtWithReplyTo = {replyTo, ...evt}; const evtWithReplyTo = {replyTo, ...evt};
replyList.push(evtWithReplyTo); replyList.push(evtWithReplyTo);
renderReply(evtWithReplyTo, relay); renderReply(evtWithReplyTo);
}; };
const handleTextNote = (evt: Event, relay: string) => { const handleTextNote = (evt: Event, relay: string) => {
@ -183,27 +148,6 @@ config.rerenderFeed = () => {
renderFeed(); renderFeed();
}; };
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => {
timeElem.textContent = formatTime(new Date(timeElem.dateTime));
});
}, 10000);
const renderRecommendServer = (evt: Event, relay: string) => {
const {img, name, time, userName} = getMetadata(evt, relay);
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [
elem('small', {}, [
elem('strong', {}, userName)
]),
]),
` recommends server: ${evt.content}`,
]);
return elemArticle([
elem('div', {className: 'mbox-img'}, [img]), body
], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}});
};
const handleRecommendServer = (evt: Event, relay: string) => { const handleRecommendServer = (evt: Event, relay: string) => {
if (getViewElem(evt.id) || !isWssUrl(evt.content)) { if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return; return;
@ -246,7 +190,7 @@ const onEvent = (evt: Event, relay: string) => {
const route = (path: string) => { const route = (path: string) => {
if (path === '/') { if (path === '/') {
sub24hFeed(onEvent); sub24hFeed(onEvent);
view('/'); view('/', {type: 'feed'});
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1)); const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') { if (typeof data !== 'string') {
@ -256,15 +200,16 @@ const route = (path: string) => {
switch(type) { switch(type) {
case 'note': case 'note':
subNote(data, onEvent); subNote(data, onEvent);
view(path); view(path, {type: 'note', id: data});
break; break;
case 'npub': case 'npub':
subProfile(data, onEvent); subProfile(data, onEvent);
view(path); view(path, {type: 'profile', id: data});
break; break;
default: default:
console.warn(`type ${type} not yet supported`); console.warn(`type ${type} not yet supported`);
} }
renderFeed();
} }
}; };
@ -273,7 +218,6 @@ route(location.pathname);
history.pushState({}, '', location.pathname); history.pushState({}, '', location.pathname);
window.addEventListener('popstate', (event) => { window.addEventListener('popstate', (event) => {
// console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`);
route(location.pathname); route(location.pathname);
}); });
@ -283,13 +227,17 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
console.warn('expected anchor to have href attribute', a); console.warn('expected anchor to have href attribute', a);
return; return;
} }
closeSettingsView();
closePublishView();
if (href === location.pathname) {
e.preventDefault();
return;
}
if ( if (
href === '/' href === '/'
|| href.startsWith('/note') || href.startsWith('/note')
|| href.startsWith('/npub') || href.startsWith('/npub')
) { ) {
closeSettingsView();
closePublishView();
route(href); route(href);
history.pushState({}, '', href); history.pushState({}, '', href);
e.preventDefault(); e.preventDefault();

@ -0,0 +1,16 @@
import {Event} from 'nostr-tools';
export type EventWithNip19 = Event & {
nip19: {
note: string;
npub: string;
}
};
export const textNoteList: Array<EventWithNip19> = []; // could use indexDB
export type EventWithNip19AndReplyTo = EventWithNip19 & {
replyTo: string;
};
export const replyList: Array<EventWithNip19AndReplyTo> = [];

@ -1,6 +1,7 @@
import {Event} from 'nostr-tools'; import {Event} from 'nostr-tools';
import {elem, elemCanvas} from './utils/dom'; import {elem, elemCanvas} from './utils/dom';
import {getHost, getNoxyUrl} from './utils/url'; import {getHost, getNoxyUrl} from './utils/url';
import {getViewContent, getViewElem} from './view';
import {validatePow} from './events'; import {validatePow} from './events';
import {parseContent} from './media'; import {parseContent} from './media';
@ -66,7 +67,8 @@ const setMetadata = (
const name = user.metadata[relay].name || user.name || ''; const name = user.metadata[relay].name || user.name || '';
if (name) { if (name) {
document.body document.body
.querySelectorAll(`[data-pubkey="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) // TODO: this should not depend on specific DOM structure, move pubkey info on username element
.querySelectorAll(`[data-pubkey="${evt.pubkey}"] > .mbox-body > header .mbox-username:not(.mbox-kind0-name)`)
.forEach((username: HTMLElement) => { .forEach((username: HTMLElement) => {
username.textContent = name; username.textContent = name;
username.classList.add('mbox-kind0-name'); username.classList.add('mbox-kind0-name');
@ -103,9 +105,11 @@ export const handleMetadata = (evt: Event, relay: string) => {
setMetadata(evt, relay, metadata); setMetadata(evt, relay, metadata);
}; };
export const getProfile = (pubkey: string) => userList.find(user => user.pubkey === pubkey);
export const getMetadata = (evt: Event, relay: string) => { export const getMetadata = (evt: Event, relay: string) => {
const host = getHost(relay); const host = getHost(relay);
const user = userList.find(user => user.pubkey === evt.pubkey); const user = getProfile(evt.pubkey);
const userImg = user?.picture; const userImg = user?.picture;
const name = user?.metadata[relay]?.name || user?.name; const name = user?.metadata[relay]?.name || user?.name;
const userName = name || evt.pubkey.slice(0, 8); const userName = name || evt.pubkey.slice(0, 8);
@ -156,3 +160,20 @@ export const getMetadata = (evt: Event, relay: string) => {
// ]); // ]);
// return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); // return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}});
// } // }
export const renderProfile = (id: string) => {
const content = getViewContent();
const header = getViewElem(id);
if (!content || !header) {
return;
}
const profile = getProfile(id);
if (profile && profile.name) {
const h1 = header.querySelector('h1');
if (h1) {
h1.textContent = profile.name;
} else {
header.prepend(elem('h1', {}, profile.name));
}
}
};

@ -1,8 +1,5 @@
/* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */ /* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */
.mbox { .mbox {
--profileimg-size: 4rem;
--profileimg-size-half: 2rem;
--profileimg-size-quarter: 1rem;
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -54,9 +51,6 @@
} }
.mbox-header { .mbox-header {
flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-grow: 0;
flex-shrink: 1;
margin-top: 0; margin-top: 0;
} }
.mbox-header a { .mbox-header a {
@ -121,21 +115,21 @@
display: block; display: block;
height: 200vh; height: 200vh;
left: var(--profileimg-size-half); left: var(--profileimg-size-half);
margin-left: -.2rem; margin-left: -.1rem;
position: absolute; position: absolute;
top: -200vh; top: -200vh;
width: .4rem; width: .2rem;
} }
.mobx-replies .mbox .mbox::before { .mobx-replies .mbox .mbox::before {
background: none; background: none;
border-color: var(--bgcolor-inactive);; border-color: var(--bgcolor-inactive);;
border-style: solid; border-style: solid;
border-width: 0 0 .4rem .4rem; border-width: 0 0 .2rem .2rem;
content: ""; content: "";
display: block; display: block;
height: var(--profileimg-size-quarter); height: var(--profileimg-size-quarter);
left: calc(-1 * var(--profileimg-size-quarter)); left: calc(-1 * var(--profileimg-size-quarter));
margin-left: -.2rem; margin-left: -.1rem;
position: absolute; position: absolute;
top: 0; top: 0;
width: .8rem; width: .8rem;
@ -147,10 +141,10 @@
display: block; display: block;
height: 100vh; height: 100vh;
left: calc(-1 * var(--profileimg-size-quarter)); left: calc(-1 * var(--profileimg-size-quarter));
margin-left: -.2rem; margin-left: -.1rem;
position: absolute; position: absolute;
top: -100vh; top: -100vh;
width: .4rem; width: .2rem;
} }
/* support visualisation of 3 levels of thread nesting, rest render flat without line */ /* support visualisation of 3 levels of thread nesting, rest render flat without line */
.mbox .mobx-replies .mobx-replies::before, .mbox .mobx-replies .mobx-replies::before,

@ -5,9 +5,10 @@
@import "error.css"; @import "error.css";
:root { :root {
--content-width: min(100% - 2.4rem, 96ch);
/* 5px auto Highlight */ /* 5px auto Highlight */
--focus-border-color: rgb(0, 122, 255); --focus-border-color: rgb(0, 122, 255);
--focus-border-radius: 2px; --focus-border-radius: .2rem;
--focus-outline-color: rgb(192, 227, 252); --focus-outline-color: rgb(192, 227, 252);
--focus-outline-offset: 2px; --focus-outline-offset: 2px;
--focus-outline-style: solid; --focus-outline-style: solid;
@ -16,7 +17,9 @@
--font-small: 1.2rem; --font-small: 1.2rem;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem; --gap-half: 1.2rem;
--content-width: min(100% - 2.4rem, 96ch); --profileimg-size: 4rem;
--profileimg-size-half: 2rem;
--profileimg-size-quarter: 1rem;
} }
::selection { ::selection {
@ -30,7 +33,8 @@
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html { html {
--bgcolor: #fdfefa; --bgcolor: #fff;
--bgcolor-nav: gainsboro;
--bgcolor-accent: #7badfc; --bgcolor-accent: #7badfc;
--bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85); --bgcolor-danger-input: rgba(255 255 255 / .85);
@ -45,6 +49,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
--bgcolor: #191919; --bgcolor: #191919;
--bgcolor-nav: darkslateblue;
--bgcolor-accent: rgb(16, 93, 176); --bgcolor-accent: rgb(16, 93, 176);
--bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5); --bgcolor-danger-input: rgba(0 0 0 / .5);
@ -74,6 +79,7 @@ body {
color: var(--color); color: var(--color);
font-size: 1.6rem; font-size: 1.6rem;
line-height: 1.5; line-height: 1.5;
word-break: break-all;
} }
html, body { html, body {
@ -119,9 +125,11 @@ a:focus {
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: 0; outline-offset: 0;
} }
a:visited { a:visited {
color: darkmagenta; color: darkslateblue;
}
nav a:visited {
color: inherit;
} }
img[alt] { img[alt] {

@ -20,18 +20,18 @@ main {
} }
aside { aside {
z-index: 2; z-index: 4;
} }
nav { nav {
background-color: indigo; background-color: var(--bgcolor-nav);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-grow: 1; flex-grow: 1;
flex-shrink: 0; flex-shrink: 0;
justify-content: space-around; justify-content: space-between;
overflow-y: auto; overflow-y: auto;
padding: 1rem 1.5rem; padding: 0 1.5rem;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
@ -46,6 +46,19 @@ nav {
justify-content: space-between; justify-content: space-between;
} }
} }
nav a,
nav button {
--bgcolor-accent: transparent;
--border-color: transparent;
border-radius: 0;
padding: 1rem;
}
@media (orientation: landscape) {
nav a,
nav button {
padding: 2rem 0;
}
}
.view { .view {
background-color: var(--bgcolor); background-color: var(--bgcolor);
@ -61,20 +74,25 @@ nav {
transition: transform .3s cubic-bezier(.465,.183,.153,.946); transition: transform .3s cubic-bezier(.465,.183,.153,.946);
width: 100%; width: 100%;
will-change: transform; will-change: transform;
z-index: 2;
} }
@media (orientation: landscape) { @media (orientation: landscape) {
.view { .view {
transition: opacity .3s cubic-bezier(.465,.183,.153,.946); transition: opacity .3s cubic-bezier(.465,.183,.153,.946);
} }
} }
.view.view-next {
z-index: 3;
}
.view.view-prev {
z-index: 1;
}
@media (orientation: portrait) { @media (orientation: portrait) {
.view.view-next { .view.view-next {
transform: translateX(100%); transform: translateX(100%);
} }
.view.view-prev { .view.view-prev {
position: relative;
transform: translateX(-20%); transform: translateX(-20%);
z-index: 0;
} }
} }
@media (orientation: landscape) { @media (orientation: landscape) {
@ -91,7 +109,7 @@ nav {
flex-grow: 1; flex-grow: 1;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
padding: var(--gap-half) 0; padding: var(--gap-half) 0 0 0;
width: 100%; width: 100%;
} }
main .content { main .content {
@ -108,3 +126,7 @@ nav a {
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
.content > header {
padding: 3rem 3rem 3rem calc(var(--profileimg-size) + var(--gap));
}

@ -0,0 +1,84 @@
import {Event} from 'nostr-tools';
import {elem, elemArticle, parseTextContent} from './utils/dom';
import {dateTime, formatTime} from './utils/time';
import {validatePow, sortByCreatedAt} from './events';
import {setViewElem} from './view';
import {config} from './settings';
import {getReactions, getReactionContents} from './reactions';
import {openWriteInput} from './write';
import {linkPreview} from './media';
import {getMetadata} from './profiles';
import {EventWithNip19, replyList} from './notes';
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => {
timeElem.textContent = formatTime(new Date(timeElem.dateTime));
});
}, 10000);
export const createTextNote = (
evt: EventWithNip19,
relay: string,
) => {
const {host, img, name, time, userName} = getMetadata(evt, relay);
const replies = replyList.filter(({replyTo}) => replyTo === evt.id);
// const isLongContent = evt.content.trimRight().length > 280;
// const content = isLongContent ? evt.content.slice(0, 280) : evt.content;
const reactions = getReactions(evt.id);
const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey);
const [content, {firstLink}] = parseTextContent(evt.content);
const buttons = elem('div', {className: 'buttons'}, [
elem('button', {name: 'reply', type: 'button'}, [
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`,
title: getReactionContents(evt.id).join(' '),
}),
elem('small', {data: {reactions: ''}}, reactions.length || ''),
]),
]);
if (localStorage.getItem('reply_to') === evt.id) {
openWriteInput(buttons, evt.id);
}
const replyFeed: Array<HTMLElement> = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : [];
return elemArticle([
elem('div', {className: 'mbox-img'}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {
className: 'mbox-header',
title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id}
${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''}
${evt.content}`
}, [
elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName),
' ',
elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))),
]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content,
(firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null,
]),
buttons,
]),
...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []),
], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
};
export const renderRecommendServer = (evt: Event, relay: string) => {
const {img, name, time, userName} = getMetadata(evt, relay);
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [
elem('small', {}, [
elem('strong', {}, userName)
]),
]),
` recommends server: ${evt.content}`,
]);
return elemArticle([
elem('div', {className: 'mbox-img'}, [img]), body
], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}});
};

@ -1,12 +1,25 @@
import {elem} from './utils/dom'; import {elem} from './utils/dom';
type ViewOptions = {
type: 'feed'
} | {
type: 'note';
id: string;
} | {
type: 'profile';
id: string;
};
type DOMMap = {
[id: string]: HTMLElement
};
type Container = { type Container = {
id: string; id: string;
options: ViewOptions,
view: HTMLElement; view: HTMLElement;
content: HTMLDivElement; content: HTMLDivElement;
dom: { dom: DOMMap;
[eventId: string]: HTMLElement
}
}; };
const containers: Array<Container> = []; const containers: Array<Container> = [];
@ -22,37 +35,56 @@ export const clearView = () => {
getViewContent().replaceChildren(); getViewContent().replaceChildren();
}; };
export const getViewElem = (eventId: string) => { export const getViewElem = (id: string) => {
return containers[activeContainerIndex]?.dom[eventId]; return containers[activeContainerIndex]?.dom[id];
}; };
export const setViewElem = (eventId: string, node: HTMLElement) => { export const setViewElem = (id: string, node: HTMLElement) => {
const container = containers[activeContainerIndex]; const container = containers[activeContainerIndex];
if (container) { if (container) {
container.dom[eventId] = node; container.dom[id] = node;
} }
return node; return node;
}; };
const mainContainer = document.querySelector('main'); const mainContainer = document.querySelector('main') as HTMLElement;
const getContainer = (route: string) => { const createContainer = (
let container = containers.find(c => c.id === route); route: string,
if (container) { options: ViewOptions,
return container; ) => {
}
const content = elem('div', {className: 'content'}); const content = elem('div', {className: 'content'});
const dom: DOMMap = {};
switch (options.type) {
case 'profile':
const header = elem('header', {},
elem('small', {}, route)
);
dom[options.id] = header;
content.append(header);
break;
case 'note':
break;
case 'feed':
break;
}
const view = elem('section', {className: 'view'}, [content]); const view = elem('section', {className: 'view'}, [content]);
mainContainer?.append(view); const container = {id: route, options, view, content, dom};
container = {id: route, view, content, dom: {}}; mainContainer.append(view);
containers.push(container); containers.push(container);
return container; return container;
}; };
export const view = (route: string) => { type GetViewOptions = () => ViewOptions;
export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};
export const view = (
route: string,
options: ViewOptions,
) => {
const active = containers[activeContainerIndex]; const active = containers[activeContainerIndex];
active?.view.classList.remove('view-active'); const nextContainer = containers.find(c => c.id === route) || createContainer(route, options);
const nextContainer = getContainer(route);
const nextContainerIndex = containers.indexOf(nextContainer); const nextContainerIndex = containers.indexOf(nextContainer);
if (nextContainerIndex === activeContainerIndex) { if (nextContainerIndex === activeContainerIndex) {
return; return;
@ -63,12 +95,6 @@ export const view = (route: string) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
nextContainer.view.classList.remove('view-next', 'view-prev'); 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'); active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev');
activeContainerIndex = nextContainerIndex; activeContainerIndex = nextContainerIndex;

Loading…
Cancel
Save