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 {zeroLeadingBitsCount} from './utils/crypto';
import {elem, elemArticle, parseTextContent} from './utils/dom';
import {bounce, dateTime, formatTime} from './utils/time';
import {elem} from './utils/dom';
import {bounce} from './utils/time';
import {isWssUrl} from './utils/url';
import {sub24hFeed, subNote, subProfile} from './subscriptions'
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events';
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view';
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
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 {linkPreview} from './media';
import {getMetadata, handleMetadata} from './profiles';
import {handleMetadata, renderProfile} from './profiles';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderRecommendServer} from './ui';
// 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 = {
[eventId: string]: string[];
};
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 = (
evt: EventWithNip19,
i: number,
@ -108,18 +45,46 @@ const hasEnoughPOW = (
};
const renderFeed = bounce(() => {
const now = Math.floor(Date.now() * 0.001);
textNoteList
// dont render notes from the future
.filter(note => note.created_at <= now)
// if difficulty filter is configured dont render notes with too little pow
.filter(note => !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id)))
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
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);
textNoteList
.filter(note => {
// dont render notes from the future
if (note.created_at > now) return false;
// if difficulty filter is configured dont render notes with too little pow
return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))
})
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
break;
}
}, 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);
if (!parent) { // root article has not been rendered
return;
@ -129,7 +94,7 @@ const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => {
replyContainer = elem('div', {className: 'mobx-replies'});
parent.append(replyContainer);
}
const reply = createTextNote(evt, relay);
const reply = createTextNote(evt, eventRelayMap[evt.id][0]);
replyContainer.append(reply);
setViewElem(evt.id, reply);
};
@ -148,7 +113,7 @@ const handleReply = (evt: EventWithNip19, relay: string) => {
}
const evtWithReplyTo = {replyTo, ...evt};
replyList.push(evtWithReplyTo);
renderReply(evtWithReplyTo, relay);
renderReply(evtWithReplyTo);
};
const handleTextNote = (evt: Event, relay: string) => {
@ -183,27 +148,6 @@ config.rerenderFeed = () => {
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) => {
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return;
@ -246,7 +190,7 @@ const onEvent = (evt: Event, relay: string) => {
const route = (path: string) => {
if (path === '/') {
sub24hFeed(onEvent);
view('/');
view('/', {type: 'feed'});
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') {
@ -256,15 +200,16 @@ const route = (path: string) => {
switch(type) {
case 'note':
subNote(data, onEvent);
view(path);
view(path, {type: 'note', id: data});
break;
case 'npub':
subProfile(data, onEvent);
view(path);
view(path, {type: 'profile', id: data});
break;
default:
console.warn(`type ${type} not yet supported`);
}
renderFeed();
}
};
@ -273,7 +218,6 @@ route(location.pathname);
history.pushState({}, '', location.pathname);
window.addEventListener('popstate', (event) => {
// console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`);
route(location.pathname);
});
@ -283,13 +227,17 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
console.warn('expected anchor to have href attribute', a);
return;
}
closeSettingsView();
closePublishView();
if (href === location.pathname) {
e.preventDefault();
return;
}
if (
href === '/'
|| href.startsWith('/note')
|| href.startsWith('/npub')
) {
closeSettingsView();
closePublishView();
route(href);
history.pushState({}, '', href);
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 {elem, elemCanvas} from './utils/dom';
import {getHost, getNoxyUrl} from './utils/url';
import {getViewContent, getViewElem} from './view';
import {validatePow} from './events';
import {parseContent} from './media';
@ -66,7 +67,8 @@ const setMetadata = (
const name = user.metadata[relay].name || user.name || '';
if (name) {
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) => {
username.textContent = name;
username.classList.add('mbox-kind0-name');
@ -103,9 +105,11 @@ export const handleMetadata = (evt: Event, relay: string) => {
setMetadata(evt, relay, metadata);
};
export const getProfile = (pubkey: string) => userList.find(user => user.pubkey === pubkey);
export const getMetadata = (evt: Event, relay: string) => {
const host = getHost(relay);
const user = userList.find(user => user.pubkey === evt.pubkey);
const user = getProfile(evt.pubkey);
const userImg = user?.picture;
const name = user?.metadata[relay]?.name || user?.name;
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}});
// }
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 */
.mbox {
--profileimg-size: 4rem;
--profileimg-size-half: 2rem;
--profileimg-size-quarter: 1rem;
align-items: center;
display: flex;
flex-direction: row;
@ -54,9 +51,6 @@
}
.mbox-header {
flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-grow: 0;
flex-shrink: 1;
margin-top: 0;
}
.mbox-header a {
@ -121,21 +115,21 @@
display: block;
height: 200vh;
left: var(--profileimg-size-half);
margin-left: -.2rem;
margin-left: -.1rem;
position: absolute;
top: -200vh;
width: .4rem;
width: .2rem;
}
.mobx-replies .mbox .mbox::before {
background: none;
border-color: var(--bgcolor-inactive);;
border-style: solid;
border-width: 0 0 .4rem .4rem;
border-width: 0 0 .2rem .2rem;
content: "";
display: block;
height: var(--profileimg-size-quarter);
left: calc(-1 * var(--profileimg-size-quarter));
margin-left: -.2rem;
margin-left: -.1rem;
position: absolute;
top: 0;
width: .8rem;
@ -147,10 +141,10 @@
display: block;
height: 100vh;
left: calc(-1 * var(--profileimg-size-quarter));
margin-left: -.2rem;
margin-left: -.1rem;
position: absolute;
top: -100vh;
width: .4rem;
width: .2rem;
}
/* support visualisation of 3 levels of thread nesting, rest render flat without line */
.mbox .mobx-replies .mobx-replies::before,

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

@ -20,18 +20,18 @@ main {
}
aside {
z-index: 2;
z-index: 4;
}
nav {
background-color: indigo;
background-color: var(--bgcolor-nav);
display: flex;
flex-direction: row;
flex-grow: 1;
flex-shrink: 0;
justify-content: space-around;
justify-content: space-between;
overflow-y: auto;
padding: 1rem 1.5rem;
padding: 0 1.5rem;
user-select: none;
-webkit-user-select: none;
}
@ -46,6 +46,19 @@ nav {
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 {
background-color: var(--bgcolor);
@ -61,20 +74,25 @@ nav {
transition: transform .3s cubic-bezier(.465,.183,.153,.946);
width: 100%;
will-change: transform;
z-index: 2;
}
@media (orientation: landscape) {
.view {
transition: opacity .3s cubic-bezier(.465,.183,.153,.946);
}
}
.view.view-next {
z-index: 3;
}
.view.view-prev {
z-index: 1;
}
@media (orientation: portrait) {
.view.view-next {
transform: translateX(100%);
}
.view.view-prev {
position: relative;
transform: translateX(-20%);
z-index: 0;
}
}
@media (orientation: landscape) {
@ -91,7 +109,7 @@ nav {
flex-grow: 1;
margin-inline: auto;
overflow-y: auto;
padding: var(--gap-half) 0;
padding: var(--gap-half) 0 0 0;
width: 100%;
}
main .content {
@ -108,3 +126,7 @@ nav a {
text-align: center;
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';
type ViewOptions = {
type: 'feed'
} | {
type: 'note';
id: string;
} | {
type: 'profile';
id: string;
};
type DOMMap = {
[id: string]: HTMLElement
};
type Container = {
id: string;
options: ViewOptions,
view: HTMLElement;
content: HTMLDivElement;
dom: {
[eventId: string]: HTMLElement
}
dom: DOMMap;
};
const containers: Array<Container> = [];
@ -22,37 +35,56 @@ export const clearView = () => {
getViewContent().replaceChildren();
};
export const getViewElem = (eventId: string) => {
return containers[activeContainerIndex]?.dom[eventId];
export const getViewElem = (id: string) => {
return containers[activeContainerIndex]?.dom[id];
};
export const setViewElem = (eventId: string, node: HTMLElement) => {
export const setViewElem = (id: string, node: HTMLElement) => {
const container = containers[activeContainerIndex];
if (container) {
container.dom[eventId] = node;
container.dom[id] = node;
}
return node;
};
const mainContainer = document.querySelector('main');
const mainContainer = document.querySelector('main') as HTMLElement;
const getContainer = (route: string) => {
let container = containers.find(c => c.id === route);
if (container) {
return container;
}
const createContainer = (
route: string,
options: ViewOptions,
) => {
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]);
mainContainer?.append(view);
container = {id: route, view, content, dom: {}};
const container = {id: route, options, view, content, dom};
mainContainer.append(view);
containers.push(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];
active?.view.classList.remove('view-active');
const nextContainer = getContainer(route);
const nextContainer = containers.find(c => c.id === route) || createContainer(route, options);
const nextContainerIndex = containers.indexOf(nextContainer);
if (nextContainerIndex === activeContainerIndex) {
return;
@ -63,12 +95,6 @@ export const view = (route: string) => {
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;

Loading…
Cancel
Save