diff --git a/esbuildconf.js b/esbuildconf.js index 0988ebd..43e48a8 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -9,11 +9,16 @@ export const options = { 'src/main.js', 'src/main.css', 'src/index.html', - 'src/bubble.svg' + 'src/assets/bubble.svg', + 'src/assets/comment.svg', + 'src/assets/heart-fill.svg', + 'src/assets/star.svg', + 'src/assets/star-fill.svg', + 'src/favicon.ico', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths - loader: {'.html': 'copy', '.svg': 'copy'}, + loader: {'.html': 'copy', '.svg': 'copy', '.ico': 'copy'}, bundle: true, platform: 'browser', minify: false, // TODO: true for release and enable sourcemap diff --git a/src/assets/bubble.svg b/src/assets/bubble.svg new file mode 100644 index 0000000..9b9d947 --- /dev/null +++ b/src/assets/bubble.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/comment.svg b/src/assets/comment.svg new file mode 100644 index 0000000..4ec1948 --- /dev/null +++ b/src/assets/comment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/heart-fill.svg b/src/assets/heart-fill.svg new file mode 100644 index 0000000..2c1f312 --- /dev/null +++ b/src/assets/heart-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/heart.svg b/src/assets/heart.svg new file mode 100644 index 0000000..b142374 --- /dev/null +++ b/src/assets/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/star-fill.svg b/src/assets/star-fill.svg new file mode 100644 index 0000000..cec0904 --- /dev/null +++ b/src/assets/star-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/star.svg b/src/assets/star.svg new file mode 100644 index 0000000..e212fa3 --- /dev/null +++ b/src/assets/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/bubble.svg b/src/bubble.svg deleted file mode 100644 index 583c79f..0000000 --- a/src/bubble.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/cards.css b/src/cards.css index d0d12ae..5db1daa 100644 --- a/src/cards.css +++ b/src/cards.css @@ -3,29 +3,62 @@ align-items: center; display: flex; flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; margin-bottom: 1rem; } -.mbox .mbox-img { - align-self: flex-start; - flex-basis: 64px; - height: 64px; +.mbox-img { + --size: 5rem; + align-self: start; + flex-basis: var(--size); + height: var(--size); margin-right: 1rem; - width: 64px; + margin-top: .5ch; + max-width: var(--size); + max-width: var(--size); } -.mbox .mbox-header { +.mbox-updated-contact .mbox-img, +.mbox-recommend-server .mbox-img { + --size: 4.5ch; + margin-left: 3ch; + margin-right: 3.5ch; +} + +.mbox-body { flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - margin-top: 0; + word-break: break-word; } -.mbox .mbox-body { - color: var(--color-accent); +.mbox-header { flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - max-width: 84ch; + margin-top: 0; +} +.mbox-header time, +.mbox-username, +.mbox-updated-contact, +.mbox mbox-recommend-server { + color: var(--color-accent); +} + +.mbox-updated-contact .mbox-body, +.mbox-recommend-server .mbox-body { + display: block; + flex-basis: 100%; + font-size: var(--font-small); + overflow: scroll; } + +.mbox-updated-contact .mbox-header, +.mbox-recommend-server .mbox-header { + display: inline; +} + +.mbox-updated-contact { + padding: 0 0 1rem 0; + margin: 0; +} \ No newline at end of file diff --git a/src/domutil.js b/src/domutil.js index bb78edb..8c780a5 100644 --- a/src/domutil.js +++ b/src/domutil.js @@ -10,9 +10,16 @@ * @param {Array} children * @return HTMLElement */ -export function elem(name = 'div', props = {}, children = []) { +export function elem(name = 'div', {data, ...props} = {}, children = []) { const el = document.createElement(name); Object.assign(el, props); - el.append(...children); + 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; } diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000..9b1f7e6 Binary files /dev/null and b/src/favicon.ico differ diff --git a/src/form.css b/src/form.css index 61a186b..1f6b11b 100644 --- a/src/form.css +++ b/src/form.css @@ -1,11 +1,12 @@ -form { - max-width: 72ch; +form, +.form { + display: flex; + flex-direction: column; } input, textarea { - color: var(--bgcolor-accent); - font-family: monospace; + color: var(--color); font-size: 1.6rem; margin-bottom: 1.2rem; padding: 1.3rem 1.8rem; @@ -13,47 +14,76 @@ textarea { button, label { - color: var(--bgcolor-accent); + cursor: pointer; display: block; font-size: 1.6rem; margin-bottom: 0; padding: 1.3rem 1.8rem; text-indent: 0; + transition: background-color .25s; +} + +label { + color: var(--color-accent); } input[type="password"], input[type="text"] { + background: var(--bgcolor-textinput); border: .2rem solid #b7b7b7; border-radius: .2rem; display: block; - outline-color: rgb(102, 102, 102); - width: 100%; + margin: 0; } input[type="password"]:focus, input[type="text"]:focus { - border-color: #d4d4d4; - outline-offset: 1px; + border-color: var(--focus-border-color); + outline-offset: 2px; } .buttons { align-items: center; display: flex; justify-content: flex-end; + margin-top: 2rem; min-height: 3.2rem; } -.button-inline { - background: transparent; - color: var(--color); - display: inline; - padding: .3rem; -} - button { background-color: var(--bgcolor-accent); border: none; - color: white; + border-radius: .2rem; cursor: pointer; + outline-offset: 1px; + word-break: normal; +} + +button:focus { +} + +.btn-inline { + align-items: center; + background: transparent; + color: var(--color); + display: inline-flex; + gap: .5ch; + line-height: 1; + padding: .6rem; +} +.btn-inline img { + max-height: 18px; + max-width: 18px; +} +.btn-inline img[alt] { + color: #7f7f7f; + line-height: 1px; +} +.btn-inline img[alt]::before { + font-size: 3.4rem; +} + +.btn-danger { + background: var(--bgcolor-danger); } button:disabled { @@ -70,3 +100,27 @@ button:disabled { flex-grow: 1; padding: 1rem 1.8rem; } + +.form-inline { + display: flex; + flex-direction: row; + flex-grow: 1; + gap: 1rem; +} +.cards .form-inline button, +.cards .form-inline input[type="text"] { + margin: .4rem 0; + padding: .6rem 1rem; +} + +.form-inline input[type="text"] { + flex-grow: 1; +} + +.form-inline button { + flex-grow: 0; +} + +.focus-active { + +} diff --git a/src/index.html b/src/index.html index cec3c31..16958a0 100644 --- a/src/index.html +++ b/src/index.html @@ -2,67 +2,75 @@ - nostr sandbox + nostr -
- -
- - -
-
+
+ + + + + + +
+
+
+ +
+
+ + +
+ +
+
+
-
- -
- - - - -
- - - - -
- - -
+ + + +
+ +
+ + + + +
+ + + + +
+
+
+
-
- - -
- - - - -
- - - - -
-
-
- -
diff --git a/src/main.css b/src/main.css index bc1312c..61d53db 100644 --- a/src/main.css +++ b/src/main.css @@ -2,6 +2,16 @@ @import "cards.css"; @import "form.css"; +:root { + /* 5px auto Highlight */ + --focus-border-color: rgb(0, 122, 255); + --focus-border-radius: 2px; + --focus-outline-color: rgb(127, 189, 247); + --focus-outline-style: solid; + --focus-outline-width: 2px; + --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); + --font-small: 1.2rem; +} ::selection { background: #ff79f9; @@ -14,23 +24,25 @@ @media (prefers-color-scheme: light) { html { - --bgcolor: #fff; - --bgcolor-accent: #ff731d; + --bgcolor: #fdfefa; + --bgcolor-accent: #37ff1d; --bgcolor-inactive: #bababa; + --bgcolor-textinput: #fff; --color: rgb(68 68 68); - --color-accent: rgb(0 0 0); - --bgcolor-danger: rgb(255 0 0); + --color-accent: #ff731d; + --bgcolor-danger: rgb(255, 80, 80); } } @media (prefers-color-scheme: dark) { html { --bgcolor: #191919; - --bgcolor-accent: #2d4263; - --bgcolor-inactive: #535353; - --color: #c84b31; - --color-accent: #ecdbba; - --bgcolor-danger: rgb(255 0 0); + --bgcolor-accent: #1e437d; + --bgcolor-inactive: #333333; + --bgcolor-textinput: #0e0e0e; + --color: #fff; + --color-accent: #bbb;; + --bgcolor-danger: rgb(169, 0, 0); } img { @@ -50,19 +62,48 @@ html { body { background-color: var(--bgcolor); color: var(--color); - font-family: monospace; font-size: 1.6rem; line-height: 1.5; } -small { - font-size: 1.2rem; +body, +button, +input, +select, +textarea { + font-family: monospace; } -*, ::after, ::before { - box-sizing: border-box; +small, +time { + font-size: var(--font-small); } .danger { background-color: var(--bgcolor-danger); } + +a { + color: var(--color-accent); +} + +a:focus { + border-radius: var(--focus-border-radius); + outline: var(--focus-outline); + outline-offset: 0; +} + +a:visited { + color: darkmagenta; +} + +img[alt] { + font-size: .9rem; + text-align: center; + word-break: break-all; +} + +pre { + margin: 0; + padding: .5rem 0; +} diff --git a/src/main.js b/src/main.js index cef8320..9fca2fd 100644 --- a/src/main.js +++ b/src/main.js @@ -1,111 +1,320 @@ -import {relayPool, generatePrivateKey, getPublicKey} from 'nostr-tools'; +import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {elem} from './domutil.js'; - +import {dateTime, formatTime} from './timeutil.js'; +// curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch const pool = relayPool(); -// pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); -pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); pool.addRelay('wss://relay.damus.io', {read: true, write: true}); +pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); +// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); +// read only +// pool.addRelay('wss://nostr.rocks', {read: true, write: false}); - -const feedlist = document.querySelector('#feedlist'); - -const dateTime = new Intl.DateTimeFormat(navigator.language, { - dateStyle: 'full', - timeStyle: 'long', -}); - -const userList = []; -let max = 0; function onEvent(evt, relay) { - if (max++ >= 7) { - return subscription.unsub(); - } switch (evt.kind) { case 0: - try { - const content = JSON.parse(evt.content); - setMetadata(userList, relay, evt, content); - } catch(err) { - console.error(err); - } + handleMetadata(evt, relay); break; case 1: - renderTextNote(evt, relay); + handleTextNote(evt, relay); break; case 2: - renderRecommendServer(evt, relay); + handleRecommendServer(evt, relay); + break; + case 3: + // handleContactList(evt, relay); break; + case 7: + handleReaction(evt, relay); default: - console.log(`TODO: add support for event kind ${evt.kind}`, evt) + // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) } } +let pubkey = localStorage.getItem('pub_key') || (() => { + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkey); + return pubkey; +})(); + const subscription = pool.sub({ cb: onEvent, filter: { - authors: [ - '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark - 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc - '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf - // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' // jb55 - ] + // authors: [ + // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark + // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc + // '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf + // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // x1ddos + // // pubkey, // me + // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 + // ], + // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), + limit: 500, } }); -function renderTextNote(evt, relay) { - const [host, img, time, userName] = getMetadata(evt, relay); +const textNoteList = []; +const eventRelayMap = {}; +const hasEventTag = tag => tag[0] === 'e'; + +function handleTextNote(evt, relay) { + if (eventRelayMap[evt.id]) { + eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; + } else { + eventRelayMap[evt.id] = [relay]; + if (evt.tags.some(hasEventTag)) { + handleReply(evt, relay); + } else { + textNoteList.push(evt); + } + renderFeed(); + } +} + +const replyList = []; +const reactionMap = {}; + +function handleReaction(evt, relay) { + if (!evt.content.length) { + // console.log('reaction with no content', evt) + return; + } + const eventTags = evt.tags.filter(hasEventTag); + let replies = eventTags.filter(([tag, eventId, relayUrl, marker]) => marker === 'reply'); + if (replies.length === 0) { + // deprecated https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated + replies = eventTags.filter((tags) => tags[3] === undefined); + } + if (replies.length !== 1) { + console.log('call me', evt); + return; + } + + const [tag, eventId/*, relayUrl, marker*/] = replies[0]; + + if (reactionMap[eventId]) { + if (reactionMap[eventId].find(reaction => reaction.id === evt.id)) { + // already received this reaction from a different relay + return; + } + reactionMap[eventId] = [evt, ...(reactionMap[eventId])]; + } else { + reactionMap[eventId] = [evt]; + } + const article = feedDomMap[eventId] || replyDomMap[eventId]; + if (article) { + const button = article.querySelector('button[name="star"]'); + const reactions = button.querySelector('[data-reactions]'); + reactions.textContent = reactionMap[eventId].length; + if (evt.pubkey === pubkey) { + const star = button.querySelector('img[src$="star.svg"]'); + star.setAttribute('src', 'assets/star-fill.svg'); + star.setAttribute('title', reactionMap[eventId]) + } + } +} + +// feed +const feedContainer = document.querySelector('#homefeed'); +const feedDomMap = {}; +const replyDomMap = window.replyDomMap = {}; + +const sortByCreatedAt = (evt1, evt2) => { + if (evt1.created_at === evt2.created_at) { + // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; +}; + +function renderFeed() { + const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); + sortedFeeds.forEach((textNoteEvent, i) => { + if (feedDomMap[textNoteEvent.id]) { + // TODO check eventRelayMap if event was published to different relays + return; + } + const article = createTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); + if (i === 0) { + feedContainer.append(article); + } else { + feedDomMap[sortedFeeds[i - 1].id].before(article); + } + feedDomMap[textNoteEvent.id] = article; + }); +} + +setInterval(() => { + document.querySelectorAll('time[datetime]').forEach(timeElem => { + timeElem.textContent = formatTime(new Date(timeElem.dateTime)); + }); +}, 10000); + +function createTextNote(evt, relay) { + const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); + const isLongContent = evt.content.length > 280; + const content = isLongContent ? `${evt.content.slice(0, 280)}…` : evt.content; + const hasReactions = reactionMap[evt.id]?.length > 0; + const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey); + const replyFeed = replies[0] ? replies.map(e => replyDomMap[e.id] = createTextNote(e, relay)) : []; + 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` : ''} + ${isReply ? `\nReply to ${evt.tags[0][1]}\n` : ''}` + }, [ + elem('small', {}, [ + elem('strong', {className: 'mbox-username'}, userName), + ' ', + elem('time', {dateTime: time.toISOString()}, formatTime(time)), + ]), + ]), + elem('div', {data: isLongContent ? {append: evt.content.slice(280)} : null}, content), + elem('button', { + className: 'btn-inline', name: 'star', type: 'button', + data: {'eventId': evt.id, relay}, + }, [ + elem('img', { + alt: didReact ? '✭' : '✩', // ♥ + height: 24, width: 24, + src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, + title: reactionMap[evt.id]?.map(({content}) => content).join(' '), + }), + elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), + ]), + elem('button', { + className: 'btn-inline', name: 'reply', type: 'button', + data: {'eventId': evt.id, relay}, + }, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]), + replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', + ]); + return rendernArticle([img, body]); +} + +function handleReply(evt, relay) { + if (replyDomMap[evt.id]) { + console.log('CALL ME already have reply in replyDomMap', evt, relay); + return; + } + replyList.push(evt); + renderReply(evt, relay); +} + +function renderReply(evt, relay) { + const eventId = evt.tags[0][1]; // TODO: double check + const article = feedDomMap[eventId] || replyDomMap[eventId]; + if (!article) { // root article has not been rendered + return; + } + let replyContainer = article.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + article.querySelector('.mbox-body').append(replyContainer); + } + const reply = createTextNote(evt, relay); + replyContainer.append(reply); + replyDomMap[evt.id] = reply; +} + +const sortEventCreatedAt = (created_at) => ( + {created_at: a}, + {created_at: b}, +) => ( + Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 +); + +function handleRecommendServer(evt, relay) { + if (feedDomMap[evt.id]) { + return; + } + const art = renderRecommendServer(evt, relay); + if (textNoteList.length < 2) { + feedContainer.append(art); + return; + } + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + feedDomMap[closestTextNotes[0].id].after(art); + feedDomMap[evt.id] = art; +} + +function handleContactList(evt, relay) { + if (feedDomMap[evt.id]) { + return; + } + const art = renderUpdateContact(evt, relay); + if (textNoteList.length < 2) { + feedContainer.append(art); + return; + } + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + feedDomMap[closestTextNotes[0].id].after(art); + feedDomMap[evt.id] = art; + // const user = userList.find(u => u.pupkey === evt.pubkey); + // if (user) { + // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); + // } else { + // tempContactList[relay] = tempContactList[relay] + // ? [...tempContactList[relay], evt] + // : [evt]; + // } +} + +function renderUpdateContact(evt, relay) { + const {img, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [ + + ]), + ]), + elem('pre', {title: JSON.stringify(evt.content)}, [ elem('strong', {}, userName), - elem('small', {},` on ${host}`), + ' updated contacts: ', + JSON.stringify(evt.tags), ]), - evt.content // text ]); - rendernArticle([img, body]); + return rendernArticle([img, body], {className: 'mbox-updated-contact'}); } function renderRecommendServer(evt, relay) { - const [host, img, time, userName] = getMetadata(evt, relay); + const {img, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ elem('header', {className: 'mbox-header'}, [ - elem('strong', {}, userName), - elem('small', {},` on ${host}`), + elem('small', {}, [ + elem('strong', {}, userName) + ]), ]), - `recommends server: ${evt.content}` + ` recommends server: ${evt.content}`, ]); - rendernArticle([img, body]); + return rendernArticle([img, body], {className: 'mbox-recommend-server', data: {relay: evt.content}}); } -function rendernArticle(content) { - const art = elem('article', {className: 'mbox'}, content); - feedlist.append(art); +function rendernArticle(content, props = {}) { + const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox'; + return elem('article', {...props, className}, content); } -function getMetadata(evt, relay) { - const {host} = new URL(relay); - const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = user?.metadata[relay]?.picture || 'bubble.svg'; - const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); - const userAbout = user?.metadata[relay]?.about || ''; - const img = elem('img', { - className: 'mbox-img', - src: userImg, - alt: `${userName}@${host}`, - title: userAbout}, - ''); - const time = new Date(evt.created_at * 1000); - return [host, img, time, userName]; +const userList = []; +// const tempContactList = {}; + +function handleMetadata(evt, relay) { + try { + const content = JSON.parse(evt.content); + setMetadata(evt, relay, content); + } catch(err) { + console.log(evt); + console.error(err); + } } -function setMetadata(userList, relay, evt, content) { +function setMetadata(evt, relay, content) { const user = userList.find(u => u.pubkey === evt.pubkey); if (!user) { userList.push({ - metadata: { - [relay]: content - }, + metadata: {[relay]: content}, pubkey: evt.pubkey, }); } else { @@ -115,46 +324,185 @@ function setMetadata(userList, relay, evt, content) { ...content, }; } + // if (tempContactList[relay]) { + // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); + // if (updates) { + // console.log('TODO: add contact list (kind 3)', updates); + // } + // } } -// settings +const getHost = (url) => { + try { + return new URL(url).host; + } catch(err) { + return err; + } +} + +function getMetadata(evt, relay) { + const host = getHost(relay); + const user = userList.find(user => user.pubkey === evt.pubkey); + const userImg = /*user?.metadata[relay]?.picture || */'assets/bubble.svg'; // TODO: enable pic once we have proxy + const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); + const userAbout = user?.metadata[relay]?.about || ''; + const img = elem('img', { + className: 'mbox-img', + src: userImg, + alt: `${userName} ${host}`, + title: `${userName} on ${host} ${userAbout}`, + }, ''); + const isReply = evt.tags.some(hasEventTag); + const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); + const time = new Date(evt.created_at * 1000); + return {host, img, isReply, replies, time, userName}; +} + +// reply +const writeForm = document.querySelector('#writeForm'); +const input = document.querySelector('input[name="message"]'); +let lastReplyBtn = null; +let replyTo = null; +feedContainer.addEventListener('click', (e) => { + const button = e.target.closest('button'); + if (button && button.name === 'reply') { + if (lastReplyBtn) { + lastReplyBtn.hidden = false; + } + lastReplyBtn = button; + // button.hidden = true; + button.after(writeForm); + button.after(sendStatus); + writeForm.hidden = false; + replyTo = ['e', button.dataset.eventId, button.dataset.relay]; + input.focus(); + return; + } + if (button && button.name === 'star') { + upvote(button.dataset.eventId, button.dataset.relay) + return; + } +}); + +const newMessageDiv = document.querySelector('#newMessage'); +document.querySelector('#bubble').addEventListener('click', (e) => { + replyTo = null; + newMessageDiv.prepend(writeForm); + newMessageDiv.append(sendStatus); + input.focus(); +}); + +async function upvote(eventId, relay) { + const privatekey = localStorage.getItem('private_key'); + const newReaction = { + kind: 7, + pubkey, // TODO: lib could check that this is the pubkey of the key to sign with + content: '+', + tags: [['e', eventId, relay, 'reply']], + created_at: Math.floor(Date.now() * 0.001), + }; + const sig = await signEvent(newReaction, privatekey).catch(console.error); + if (sig) { + const ev = await pool.publish({...newReaction, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + console.info(`event published by ${url}`); + } + }).catch(console.error); + } +} +// send +const sendStatus = document.querySelector('#sendstatus'); +const onSendError = err => { + sendStatus.textContent = err.message; + sendStatus.hidden = false; +}; +const publish = document.querySelector('#publish'); +writeForm.addEventListener('submit', async (e) => { + e.preventDefault(); + // const pubkey = localStorage.getItem('pub_key'); + const privatekey = localStorage.getItem('private_key'); + if (!pubkey || !privatekey) { + return onSendError(new Error('no pubkey/privatekey')); + } + if (!input.value) { + return onSendError(new Error('message is empty')); + } + const tags = replyTo ? [replyTo] : []; + const newEvent = { + kind: 1, + pubkey, + content: input.value, + tags, + created_at: Math.floor(Date.now() * 0.001), + }; + const sig = await signEvent(newEvent, privatekey).catch(onSendError); + if (sig) { + const ev = await pool.publish({...newEvent, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + sendStatus.hidden = true; + input.value = ''; + publish.disabled = true; + if (lastReplyBtn) { + lastReplyBtn.hidden = false; + lastReplyBtn = null; + replyTo = null; + newMessageDiv.append(writeForm); + newMessageDiv.append(sendStatus); + } + // console.info(`event published by ${url}`, ev); + } + }); + } +}); + +input.addEventListener('input', () => publish.disabled = !input.value); +input.addEventListener('blur', () => sendStatus.textContent = ''); + +// settings const form = document.querySelector('form[name="settings"]'); const privateKeyInput = form.querySelector('#privatekey'); const pubKeyInput = form.querySelector('#pubkey'); const statusMessage = form.querySelector('#keystatus'); const generateBtn = form.querySelector('button[name="generate"]'); const importBtn = form.querySelector('button[name="import"]'); -const privateTgl = form.querySelector('button[name="privatekey-toggle"]') +const privateTgl = form.querySelector('button[name="privatekey-toggle"]'); generateBtn.addEventListener('click', () => { - const privateKey = generatePrivateKey(); - const pubKey = getPublicKey(privateKey); - if (validKeys(privateKey, pubKey)) { - privateKeyInput.value = privateKey; - pubKeyInput.value = pubKey; + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + if (validKeys(privatekey, pubkey)) { + privateKeyInput.value = privatekey; + pubKeyInput.value = pubkey; statusMessage.textContent = 'private-key created!'; statusMessage.hidden = false; } }); importBtn.addEventListener('click', () => { - const privateKey = privateKeyInput.value; - const pubKey = pubKeyInput.value; - if (validKeys(privateKey, pubKey)) { - localStorage.setItem('privateKey', privateKey); - localStorage.setItem('pubKey', pubKey); - statusMessage.textContent = 'private-key saved in local storage!'; + const privatekey = privateKeyInput.value; + const pubkeyInput = pubKeyInput.value; + if (validKeys(privatekey, pubkeyInput)) { + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkeyInput); + statusMessage.textContent = 'stored private and public key locally!'; statusMessage.hidden = false; + pubkey = pubkeyInput; } }); form.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value)); -function validKeys(privateKey, pubKey) { - if (pubKey && privateKey) { +function validKeys(privatekey, pubkey) { + if (pubkey && privatekey) { try { - if (getPublicKey(privateKey) === pubKey) { + if (getPublicKey(privatekey) === pubkey) { statusMessage.hidden = true; statusMessage.textContent = 'public-key corresponds to private-key'; importBtn.removeAttribute('disabled'); @@ -168,12 +516,21 @@ function validKeys(privateKey, pubKey) { } statusMessage.hidden = false; importBtn.setAttribute('disabled', true); - return false; + return false; } privateTgl.addEventListener('click', () => { privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; }); -privateKeyInput.value = localStorage.getItem('privateKey'); -pubKeyInput.value = localStorage.getItem('pubKey'); +privateKeyInput.value = localStorage.getItem('private_key'); +pubKeyInput.value = localStorage.getItem('pub_key'); + +document.body.addEventListener('click', (e) => { + const append = e.target.closest('[data-append]'); + if (append) { + append.textContent += append.dataset.append; + delete append.dataset.append; + return; + } +}); diff --git a/src/tabs.css b/src/tabs.css index 02cf5a3..e92c055 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -1,18 +1,12 @@ -.tabs { - position: relative; - min-height: 200px; -} - -.tab { - float: left; -} - -.tab > label { - cursor: pointer; - padding: 1rem 1.5em; -} - -.tab [type=radio] { +.tabs { margin-top: 4rem; } +.tabs .tab-content { display: none; } +#feed:checked ~ .tabs .tab-content:nth-child(1), +#trending:checked ~ .tabs .tab-content:nth-child(2), +#direct:checked ~ .tabs .tab-content:nth-child(3), +#chat:checked ~ .tabs .tab-content:nth-child(4), +#settings:checked ~ .tabs .tab-content:nth-child(5) { display: block; } + +input[type="radio"].tab { clip: rect(0, 0, 0, 0); height: 0; overflow: hidden; @@ -20,33 +14,42 @@ width: 0; } -.tab [type=radio] + label { +.tab + label { + border: none; + color: var(--color); + display: inline-block; outline: 2px solid var(--bgcolor-accent); - outline-offset: -1px; + padding: 1rem 1.5em; + position: relative; + top: 1px; +} +input[type="radio"]:checked + label { + background: var(--bgcolor-accent); } -/* -.tab [type=radio]:focus + label { - outline: 2px dotted black; +.tab:focus + label, +.tab:active + label { + border-color: var(--focus-border-color); + border-radius: var(--focus-border-radius); + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); } -*/ -.tab [type=radio]:checked ~ label { - background-color: var(--bgcolor-accent); - color: var(--color-accent); - z-index: 2; +.tab-content { + max-width: 96ch; + min-height: 200px; } -.tab [type=radio]:checked ~ label ~ .content { - opacity: 1; - z-index: 1; +/* + + + +.tab { + float: left; } -.tab .content { - bottom: 0; - left: 0; - opacity: 0; - position: absolute; - right: 0; - top: 5em; +.tab > label { } + + +*/ \ No newline at end of file diff --git a/src/timeutil.js b/src/timeutil.js new file mode 100644 index 0000000..f798f83 --- /dev/null +++ b/src/timeutil.js @@ -0,0 +1,65 @@ +/** + * Intl.DateTimeFormat object + * + * example: + * + * console.log(dateTime.format(new Date())); + */ +export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * format time relative to now, such as 5min ago + * + * @param {Date} time + * @param {string} locale + * @returns string + * + * example: + * + * console.log(timeAgo(new Date(Date.now() - 10000))); + * + */ +const timeAgo = (time, locale = 'en') => { + const relativeTime = new Intl.RelativeTimeFormat(locale, { + numeric: 'auto', + style: 'long', + }); + const timeSince = (Date.now() - time.getTime()) * 0.001; + const minutes = Math.floor(timeSince / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + + if (years > 0) { + return relativeTime.format(0 - years, 'year'); + } else if (months > 0) { + return relativeTime.format(0 - months, 'month'); + } else if (days > 0) { + return relativeTime.format(0 - days, 'day'); + } else if (hours > 0) { + return relativeTime.format(0 - hours, 'hour'); + } else if (minutes > 0) { + return relativeTime.format(0 - minutes, 'minute'); + } else { + return relativeTime.format(Math.round(0 - timeSince), 'second'); + } +}; + +/** + * formatTime shows relative time if it is less than 24h else absolute datetime + * + * @param {time} date object to format + * @return string + */ +export const formatTime = (time) => { + const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000)); + if (time > yesterday) { + return timeAgo(time); + } else { + return dateTime.format(time); + }; +};