diff --git a/src/domutil.js b/src/domutil.js deleted file mode 100644 index bbdb1ee..0000000 --- a/src/domutil.js +++ /dev/null @@ -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} 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}]; -} diff --git a/src/main.js b/src/main.js index 0434cb0..31e9a3c 100644 --- a/src/main.js +++ b/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) { diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 5ba1f1a..0000000 --- a/src/utils.js +++ /dev/null @@ -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); - }; -}; diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000..cd00032 --- /dev/null +++ b/src/utils/dom.ts @@ -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} children + * @return HTMLElement + */ +export const elem = ( + name: keyof HTMLElementTagNameMap, + attrs: Attributes = {}, + children: Array | 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
click https://nostr.ch/'); + * + * @param {string} content + * @returns [Array, {firstLink: href}] + */ +export const parseTextContent = ( + content: string, +): [ + Array, + {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} + ]; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..5ca6ee9 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export {elem, parseTextContent} from './dom'; +export {bounce, dateTime, formatTime} from './time'; diff --git a/src/timeutil.js b/src/utils/time.ts similarity index 66% rename from src/timeutil.js rename to src/utils/time.ts index f798f83..a44d035 100644 --- a/src/timeutil.js +++ b/src/utils/time.ts @@ -1,8 +1,34 @@ +/** + * 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 - * + * * example: - * + * * console.log(dateTime.format(new Date())); */ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { @@ -12,17 +38,20 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */ /** * format time relative to now, such as 5min ago - * - * @param {Date} time + * + * @param {Date} time * @param {string} locale * @returns string - * + * * example: - * + * * 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); diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..03e0180 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,76 @@ +import {elem} from './utils'; + +type Container = { + id: string; + view: HTMLSelectElement; + content: HTMLDivElement; + dom: { + [eventId: string]: HTMLElement + } +}; + +const containers: Array = []; + +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; + }); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ebb6d9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "es2021" + }, + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file