forked from nostr/nostrweb
refactor: type view.ts, dom.ts and time.ts
parent
35b8baef92
commit
fa97027321
|
@ -1,92 +0,0 @@
|
||||||
/**
|
|
||||||
* example usage:
|
|
||||||
*
|
|
||||||
* const props = {className: 'btn', onclick: async (e) => alert('hi')};
|
|
||||||
* const btn = elem('button', props, ['download']);
|
|
||||||
* document.body.append(btn);
|
|
||||||
*
|
|
||||||
* @param {string} name
|
|
||||||
* @param {HTMLElement.prototype} props
|
|
||||||
* @param {Array<HTMLElement|string>} children
|
|
||||||
* @return HTMLElement
|
|
||||||
*/
|
|
||||||
export function elem(name = 'div', {data, ...props} = {}, children = []) {
|
|
||||||
const el = document.createElement(name);
|
|
||||||
Object.assign(el, props);
|
|
||||||
if (['number', 'string'].includes(typeof children)) {
|
|
||||||
el.append(children);
|
|
||||||
} else {
|
|
||||||
el.append(...children);
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
Object.entries(data).forEach(([key, value]) => el.dataset[key] = value);
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidURL(url) {
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!['', '443', '80'].includes(url.port)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (url.hostname === 'localhost') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const lastDot = url.hostname.lastIndexOf('.');
|
|
||||||
if (lastDot < 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (url.hostname.slice(lastDot) === '.local') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTextContent(string) {
|
|
||||||
let firstLink;
|
|
||||||
return [string
|
|
||||||
.trimRight()
|
|
||||||
.replaceAll(/\n{3,}/g, '\n\n')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => {
|
|
||||||
const words = line.split(/\s/);
|
|
||||||
return words.map(word => {
|
|
||||||
if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) {
|
|
||||||
return elem('a', {
|
|
||||||
href: `lightning:${word}`
|
|
||||||
}, `lightning:${word.slice(0, 24)}…`);
|
|
||||||
}
|
|
||||||
if (!word.match(/^(https?:\/\/|www\.)\S*/)) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!word.startsWith('http')) {
|
|
||||||
word = 'https://' + word;
|
|
||||||
}
|
|
||||||
const url = new URL(word);
|
|
||||||
if (!isValidURL(url)) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
firstLink = firstLink || url.href;
|
|
||||||
return elem('a', {
|
|
||||||
href: url.href,
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener noreferrer'
|
|
||||||
}, url.href.slice(url.protocol.length + 2));
|
|
||||||
} catch (err) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.reduce((acc, word) => [...acc, word, ' '], []);
|
|
||||||
})
|
|
||||||
.reduce((acc, words) => [...acc, ...words, elem('br')], []),
|
|
||||||
{firstLink}];
|
|
||||||
}
|
|
78
src/main.js
78
src/main.js
|
@ -1,10 +1,9 @@
|
||||||
import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools';
|
import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools';
|
||||||
import {sub24hFeed, subNote, subProfile} from './subscriptions'
|
import {sub24hFeed, subNote, subProfile} from './subscriptions'
|
||||||
import {publish} from './relays';
|
import {publish} from './relays';
|
||||||
import {bounce} from './utils.js';
|
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view';
|
||||||
import {zeroLeadingBitsCount} from './cryptoutils.js';
|
import {zeroLeadingBitsCount} from './cryptoutils.js';
|
||||||
import {elem, parseTextContent} from './domutil.js';
|
import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils';
|
||||||
import {dateTime, formatTime} from './timeutil.js';
|
|
||||||
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
||||||
|
|
||||||
function onEvent(evt, relay) {
|
function onEvent(evt, relay) {
|
||||||
|
@ -36,16 +35,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => {
|
||||||
return pubkey;
|
return pubkey;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const containers = [
|
|
||||||
// {
|
|
||||||
// id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d',
|
|
||||||
// view: Node,
|
|
||||||
// content: Node,
|
|
||||||
// dom: {}
|
|
||||||
// }
|
|
||||||
];
|
|
||||||
let activeContainerIndex = null;
|
|
||||||
|
|
||||||
|
|
||||||
const textNoteList = []; // could use indexDB
|
const textNoteList = []; // could use indexDB
|
||||||
const eventRelayMap = {}; // eventId: [relay1, relay2]
|
const eventRelayMap = {}; // eventId: [relay1, relay2]
|
||||||
|
@ -180,9 +169,7 @@ const sortByCreatedAt = (evt1, evt2) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function rerenderFeed() {
|
function rerenderFeed() {
|
||||||
const domMap = getViewDom(); // TODO: this is only the current view, do this for all views
|
clearView();
|
||||||
Object.keys(domMap).forEach(key => delete domMap[key]);
|
|
||||||
getViewContent().replaceChildren([]);
|
|
||||||
renderFeed();
|
renderFeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -719,68 +706,11 @@ 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 ------------')
|
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribe and change view
|
// subscribe and change view
|
||||||
function route(path) {
|
function route(path) {
|
||||||
|
|
22
src/utils.js
22
src/utils.js
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* throttle and debounce given function in regular time interval,
|
|
||||||
* but with the difference that the last call will be debounced and therefore never missed.
|
|
||||||
* @param {*} function to throttle and debounce
|
|
||||||
* @param {*} time desired interval to execute function
|
|
||||||
* @returns callback
|
|
||||||
*/
|
|
||||||
export const bounce = (fn, time) => {
|
|
||||||
let throttle;
|
|
||||||
let debounce;
|
|
||||||
return (/*...args*/) => {
|
|
||||||
if (throttle) {
|
|
||||||
clearTimeout(debounce);
|
|
||||||
debounce = setTimeout(() => fn(/*...args*/), time);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fn(/*...args*/);
|
|
||||||
throttle = setTimeout(() => {
|
|
||||||
throttle = false;
|
|
||||||
}, time);
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
type Attributes = {
|
||||||
|
[key: string]: string | number;
|
||||||
|
} & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* example usage:
|
||||||
|
*
|
||||||
|
* const props = {className: 'btn', onclick: async (e) => alert('hi')};
|
||||||
|
* const btn = elem('button', props, ['download']);
|
||||||
|
* document.body.append(btn);
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {HTMLElement.prototype} props
|
||||||
|
* @param {Array<HTMLElement|string>} children
|
||||||
|
* @return HTMLElement
|
||||||
|
*/
|
||||||
|
export const elem = (
|
||||||
|
name: keyof HTMLElementTagNameMap,
|
||||||
|
attrs: Attributes = {},
|
||||||
|
children: Array<Node> | string = []
|
||||||
|
) => {
|
||||||
|
const {data, ...props} = attrs;
|
||||||
|
const el = document.createElement(name);
|
||||||
|
Object.assign(el, props);
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
el.append(...children);
|
||||||
|
} else {
|
||||||
|
const childType = typeof children;
|
||||||
|
if (childType === 'number' || childType === 'string') {
|
||||||
|
el.append(children);
|
||||||
|
} else {
|
||||||
|
console.error('call me');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
el.dataset[key] = value as string;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidURL = (url: URL) => {
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!['', '443', '80'].includes(url.port)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.hostname === 'localhost') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lastDot = url.hostname.lastIndexOf('.');
|
||||||
|
if (lastDot < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.hostname.slice(lastDot) === '.local') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* example usage:
|
||||||
|
*
|
||||||
|
* const [content, {firstLink}] = parseTextContent('Hi<br>click https://nostr.ch/');
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns [Array<string | HTMLElement>, {firstLink: href}]
|
||||||
|
*/
|
||||||
|
export const parseTextContent = (
|
||||||
|
content: string,
|
||||||
|
): [
|
||||||
|
Array<string | HTMLAnchorElement | HTMLBRElement>,
|
||||||
|
{firstLink: string | undefined},
|
||||||
|
] => {
|
||||||
|
let firstLink: string | undefined;
|
||||||
|
const parsedContent = content
|
||||||
|
.trim()
|
||||||
|
.replaceAll(/\n{3,}/g, '\n\n')
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const words = line.split(/\s/);
|
||||||
|
return words.map(word => {
|
||||||
|
if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) {
|
||||||
|
return elem('a', {
|
||||||
|
href: `lightning:${word}`
|
||||||
|
}, `lightning:${word.slice(0, 24)}…`);
|
||||||
|
}
|
||||||
|
if (!word.match(/^(https?:\/\/|www\.)\S*/)) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!word.startsWith('http')) {
|
||||||
|
word = 'https://' + word;
|
||||||
|
}
|
||||||
|
const url = new URL(word);
|
||||||
|
if (!isValidURL(url)) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
firstLink = firstLink || url.href;
|
||||||
|
return elem('a', {
|
||||||
|
href: url.href,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer'
|
||||||
|
}, url.href.slice(url.protocol.length + 2));
|
||||||
|
} catch (err) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.reduce((acc, word) => [...acc, word, ' '], []);
|
||||||
|
})
|
||||||
|
.reduce((acc, words) => [...acc, ...words, elem('br')], []);
|
||||||
|
|
||||||
|
return [
|
||||||
|
parsedContent,
|
||||||
|
{firstLink}
|
||||||
|
];
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
export {elem, parseTextContent} from './dom';
|
||||||
|
export {bounce, dateTime, formatTime} from './time';
|
|
@ -1,3 +1,29 @@
|
||||||
|
/**
|
||||||
|
* throttle and debounce given function in regular time interval,
|
||||||
|
* but with the difference that the last call will be debounced and therefore never missed.
|
||||||
|
* @param {*} function to throttle and debounce
|
||||||
|
* @param {*} time desired interval to execute function
|
||||||
|
* @returns callback
|
||||||
|
*/
|
||||||
|
export const bounce = (
|
||||||
|
fn: () => void,
|
||||||
|
time: number,
|
||||||
|
) => {
|
||||||
|
let throttle;
|
||||||
|
let debounce;
|
||||||
|
return (/*...args*/) => {
|
||||||
|
if (throttle) {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => fn(/*...args*/), time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fn(/*...args*/);
|
||||||
|
throttle = setTimeout(() => {
|
||||||
|
throttle = false;
|
||||||
|
}, time);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intl.DateTimeFormat object
|
* Intl.DateTimeFormat object
|
||||||
*
|
*
|
||||||
|
@ -22,7 +48,10 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */
|
||||||
* console.log(timeAgo(new Date(Date.now() - 10000)));
|
* console.log(timeAgo(new Date(Date.now() - 10000)));
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const timeAgo = (time, locale = 'en') => {
|
const timeAgo = (
|
||||||
|
time: Date,
|
||||||
|
locale: string = 'en',
|
||||||
|
) => {
|
||||||
const relativeTime = new Intl.RelativeTimeFormat(locale, {
|
const relativeTime = new Intl.RelativeTimeFormat(locale, {
|
||||||
numeric: 'auto',
|
numeric: 'auto',
|
||||||
style: 'long',
|
style: 'long',
|
||||||
|
@ -55,7 +84,7 @@ const timeAgo = (time, locale = 'en') => {
|
||||||
* @param {time} date object to format
|
* @param {time} date object to format
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
export const formatTime = (time) => {
|
export const formatTime = (time: Date) => {
|
||||||
const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000));
|
const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000));
|
||||||
if (time > yesterday) {
|
if (time > yesterday) {
|
||||||
return timeAgo(time);
|
return timeAgo(time);
|
|
@ -0,0 +1,76 @@
|
||||||
|
import {elem} from './utils';
|
||||||
|
|
||||||
|
type Container = {
|
||||||
|
id: string;
|
||||||
|
view: HTMLSelectElement;
|
||||||
|
content: HTMLDivElement;
|
||||||
|
dom: {
|
||||||
|
[eventId: string]: HTMLElement
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containers: Array<Container> = [];
|
||||||
|
|
||||||
|
let activeContainerIndex = -1;
|
||||||
|
|
||||||
|
export const getViewContent = () => containers[activeContainerIndex]?.content;
|
||||||
|
|
||||||
|
export const clearView = () => {
|
||||||
|
// TODO: this is clears the current view, but it should probably do this for all views
|
||||||
|
const domMap = containers[activeContainerIndex]?.dom;
|
||||||
|
Object.keys(domMap).forEach(eventId => delete domMap[eventId]);
|
||||||
|
getViewContent().replaceChildren();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getViewElem = (eventId: string) => {
|
||||||
|
return containers[activeContainerIndex]?.dom[eventId];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setViewElem = (eventId: string, node: HTMLElement) => {
|
||||||
|
const container = containers[activeContainerIndex];
|
||||||
|
if (container) {
|
||||||
|
container.dom[eventId] = node;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContainer = document.querySelector('main');
|
||||||
|
|
||||||
|
const getContainer = (route: string) => {
|
||||||
|
let container = containers.find(c => c.id === route);
|
||||||
|
if (container) {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
const content = elem('div', {className: 'content'});
|
||||||
|
const view = elem('section', {className: 'view'}, [content]);
|
||||||
|
mainContainer?.append(view);
|
||||||
|
container = {id: route, view, content, dom: {}};
|
||||||
|
containers.push(container);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const view = (route: string) => {
|
||||||
|
const active = containers[activeContainerIndex];
|
||||||
|
active?.view.classList.remove('view-active');
|
||||||
|
const nextContainer = getContainer(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;
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2021"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
Loading…
Reference in New Issue