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 {sub24hFeed, subNote, subProfile} from './subscriptions'
|
||||
import {publish} from './relays';
|
||||
import {bounce} from './utils.js';
|
||||
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view';
|
||||
import {zeroLeadingBitsCount} from './cryptoutils.js';
|
||||
import {elem, parseTextContent} from './domutil.js';
|
||||
import {dateTime, formatTime} from './timeutil.js';
|
||||
import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils';
|
||||
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
||||
|
||||
function onEvent(evt, relay) {
|
||||
|
@ -36,16 +35,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => {
|
|||
return pubkey;
|
||||
})();
|
||||
|
||||
const containers = [
|
||||
// {
|
||||
// id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d',
|
||||
// view: Node,
|
||||
// content: Node,
|
||||
// dom: {}
|
||||
// }
|
||||
];
|
||||
let activeContainerIndex = null;
|
||||
|
||||
|
||||
const textNoteList = []; // could use indexDB
|
||||
const eventRelayMap = {}; // eventId: [relay1, relay2]
|
||||
|
@ -180,9 +169,7 @@ const sortByCreatedAt = (evt1, evt2) => {
|
|||
};
|
||||
|
||||
function rerenderFeed() {
|
||||
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([]);
|
||||
clearView();
|
||||
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 ------------')
|
||||
|
||||
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
|
||||
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
|
||||
*
|
||||
|
@ -22,7 +48,10 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */
|
|||
* 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, {
|
||||
numeric: 'auto',
|
||||
style: 'long',
|
||||
|
@ -55,7 +84,7 @@ const timeAgo = (time, locale = 'en') => {
|
|||
* @param {time} date object to format
|
||||
* @return string
|
||||
*/
|
||||
export const formatTime = (time) => {
|
||||
export const formatTime = (time: Date) => {
|
||||
const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000));
|
||||
if (time > yesterday) {
|
||||
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