Merge branch 'change-everything'

pull/13/head
OFF0 2 years ago
commit 56da8772db
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 1000 1000" version="1.1" viewBox="0 0 1e3 1e3" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path fill="#7f7f7f" d="m240.4 673.3c-67.5 0-122.5 44.8-122.5 100 0 55.1 55 100 122.5 100s122.5-44.8 122.5-100c0-55.1-54.9-100-122.5-100zm0 160.3c-45.7 0-82.8-27-82.8-60.3 0-33.2 37.1-60.3 82.8-60.3s82.8 27 82.8 60.3c0 33.2-37.1 60.3-82.8 60.3z"/>
<path fill="#7f7f7f" d="m108.5 865.9c-37.1 0-67.3 25.5-67.3 56.9s30.2 56.9 67.3 56.9 67.3-25.5 67.3-56.9-30.2-56.9-67.3-56.9zm0 74c-15.8 0-27.6-9.1-27.6-17.2s11.3-17.2 27.6-17.2c15.8 0 27.6 9.1 27.6 17.2s-11.8 17.2-27.6 17.2z"/>
<path fill="#7f7f7f" d="m989.1 391c-5.4-48.7-35.5-92-83-120.4 0-5.9-0.3-11.5-0.9-16.8-10.2-91.8-93.6-161.2-194.7-162.8-38.1-50-102.5-77.1-171.9-69.3-35.3 4-68 16.6-95.5 36.8-28.5-25-67.5-37.4-108.8-32.8-58.6 6.5-106.6 47.2-119.1 99.1-130.6 36.7-217.5 153.8-203.8 277.4 13.7 122.4 120.2 216.1 254.6 225.3 28.3 24.1 69.3 35.8 112.4 31.1 19-2.1 37.5-7.5 54.1-15.7 31.8 22.3 72.4 34.4 115.8 34.4 8.6 0 17.2-0.5 25.8-1.4 62.8-7 117.7-38.7 147-84.1 12 1.6 24.1 2.4 36.2 2.4 10.1 0 20.3-0.6 30.4-1.7 122-13.6 212.3-104 201.4-201.5zm-205.6 162c-8.7 1-17.4 1.4-26 1.4-14.4 0-28.8-1.3-42.8-4l-14.5-2.7-6.8 13.2c-20.8 40.4-68.1 69.3-123.5 75.5-47 5.2-92.4-7.1-122.8-32.4l-10.9-9.1-12.1 7.4c-14.6 9-31.9 14.8-49.9 16.8-34 3.8-66.1-6-86-25.3l-5.4-5.3-7.5-0.3c-118.2-5.4-212.5-85.4-224.2-190.5-12-107.6 68-209.7 186.1-237.6l13.6-3.2 1.6-13.8c4.7-40.1 41.1-72.9 86.6-78 34.5-3.8 67.7 9 87.7 32.8l13.2 15.8 15.3-13.8c24.1-21.7 54.6-35.2 88.2-38.9 57.5-6.4 112.3 18.1 140.8 60.8l6.1 9.2 17.1-0.5c82.3 0 150.6 54.8 158.7 127.5 0.8 6.8 0.9 14.2 0.4 22.6l-0.8 12.9 11.5 6c42 21.9 68.6 56.7 72.9 95.8 8 75.9-66.6 146.5-166.6 157.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031"><path fill="transparent" stroke="#7f7f7f" stroke-width="4.927" d="M2.463 31.824q0-4.789 1.893-9.248 1.892-4.46 5.361-8.087 3.47-3.626 8.07-6.333 4.598-2.707 10.324-4.2 5.727-1.493 11.836-1.493 6.107 0 11.834 1.492 5.725 1.494 10.325 4.2 4.599 2.708 8.07 6.334 3.47 3.627 5.362 8.087 1.891 4.46 1.891 9.248 0 5.97-2.967 11.384-2.967 5.414-7.982 9.336-5.015 3.922-11.957 6.248-6.94 2.325-14.576 2.325-7.463 0-14.334-2.221l-8.537 6.038q-4.789 3.02-6.733 1.752-1.943-1.266-.867-7.13l1.77-8.886q-4.2-3.887-6.49-8.71-2.29-4.825-2.29-10.136Z"/></svg>

After

Width:  |  Height:  |  Size: 634 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path d="M14 20.408c-.492.308-.903.546-1.192.709-.153.086-.308.17-.463.252h-.002a.75.75 0 01-.686 0 16.709 16.709 0 01-.465-.252 31.147 31.147 0 01-4.803-3.34C3.8 15.572 1 12.331 1 8.513 1 5.052 3.829 2.5 6.736 2.5 9.03 2.5 10.881 3.726 12 5.605 13.12 3.726 14.97 2.5 17.264 2.5 20.17 2.5 23 5.052 23 8.514c0 3.818-2.801 7.06-5.389 9.262A31.146 31.146 0 0114 20.408z"></path></svg>

After

Width:  |  Height:  |  Size: 464 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill-rule="evenodd" d="M4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.565 20.565 0 008 13.393a20.561 20.561 0 003.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.75.75 0 01-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5zM8 14.25l-.345.666-.002-.001-.006-.003-.018-.01a7.643 7.643 0 01-.31-.17 22.075 22.075 0 01-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.08 22.08 0 01-3.744 2.584l-.018.01-.006.003h-.002L8 14.25zm0 0l.345.666a.752.752 0 01-.69 0L8 14.25z"></path></svg>

After

Width:  |  Height:  |  Size: 734 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#7f7f7f" d="M12.672.668a.75.75 0 00-1.345 0L8.27 6.865l-6.838.994a.75.75 0 00-.416 1.279l4.948 4.823-1.168 6.811a.75.75 0 001.088.791L12 18.347l6.117 3.216a.75.75 0 001.088-.79l-1.168-6.812 4.948-4.823a.75.75 0 00-.416-1.28l-6.838-.993L12.672.668z"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#7f7f7f" d="M12 .25a.75.75 0 01.673.418l3.058 6.197 6.839.994a.75.75 0 01.415 1.279l-4.948 4.823 1.168 6.811a.75.75 0 01-1.088.791L12 18.347l-6.117 3.216a.75.75 0 01-1.088-.79l1.168-6.812-4.948-4.823a.75.75 0 01.416-1.28l6.838-.993L11.328.668A.75.75 0 0112 .25zm0 2.445L9.44 7.882a.75.75 0 01-.565.41l-5.725.832 4.143 4.038a.75.75 0 01.215.664l-.978 5.702 5.121-2.692a.75.75 0 01.698 0l5.12 2.692-.977-5.702a.75.75 0 01.215-.664l4.143-4.038-5.725-.831a.75.75 0 01-.565-.41L12 2.694z"></path></svg>

After

Width:  |  Height:  |  Size: 592 B

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 1000 1000" version="1.1" viewBox="0 0 1e3 1e3" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="m240.4 673.3c-67.5 0-122.5 44.8-122.5 100 0 55.1 55 100 122.5 100s122.5-44.8 122.5-100c0-55.1-54.9-100-122.5-100zm0 160.3c-45.7 0-82.8-27-82.8-60.3 0-33.2 37.1-60.3 82.8-60.3s82.8 27 82.8 60.3c0 33.2-37.1 60.3-82.8 60.3z"/><path d="m108.5 865.9c-37.1 0-67.3 25.5-67.3 56.9s30.2 56.9 67.3 56.9 67.3-25.5 67.3-56.9-30.2-56.9-67.3-56.9zm0 74c-15.8 0-27.6-9.1-27.6-17.2s11.3-17.2 27.6-17.2c15.8 0 27.6 9.1 27.6 17.2s-11.8 17.2-27.6 17.2z"/><path d="m989.1 391c-5.4-48.7-35.5-92-83-120.4 0-5.9-0.3-11.5-0.9-16.8-10.2-91.8-93.6-161.2-194.7-162.8-38.1-50-102.5-77.1-171.9-69.3-35.3 4-68 16.6-95.5 36.8-28.5-25-67.5-37.4-108.8-32.8-58.6 6.5-106.6 47.2-119.1 99.1-130.6 36.7-217.5 153.8-203.8 277.4 13.7 122.4 120.2 216.1 254.6 225.3 28.3 24.1 69.3 35.8 112.4 31.1 19-2.1 37.5-7.5 54.1-15.7 31.8 22.3 72.4 34.4 115.8 34.4 8.6 0 17.2-0.5 25.8-1.4 62.8-7 117.7-38.7 147-84.1 12 1.6 24.1 2.4 36.2 2.4 10.1 0 20.3-0.6 30.4-1.7 122-13.6 212.3-104 201.4-201.5zm-205.6 162c-8.7 1-17.4 1.4-26 1.4-14.4 0-28.8-1.3-42.8-4l-14.5-2.7-6.8 13.2c-20.8 40.4-68.1 69.3-123.5 75.5-47 5.2-92.4-7.1-122.8-32.4l-10.9-9.1-12.1 7.4c-14.6 9-31.9 14.8-49.9 16.8-34 3.8-66.1-6-86-25.3l-5.4-5.3-7.5-0.3c-118.2-5.4-212.5-85.4-224.2-190.5-12-107.6 68-209.7 186.1-237.6l13.6-3.2 1.6-13.8c4.7-40.1 41.1-72.9 86.6-78 34.5-3.8 67.7 9 87.7 32.8l13.2 15.8 15.3-13.8c24.1-21.7 54.6-35.2 88.2-38.9 57.5-6.4 112.3 18.1 140.8 60.8l6.1 9.2 17.1-0.5c82.3 0 150.6 54.8 158.7 127.5 0.8 6.8 0.9 14.2 0.4 22.6l-0.8 12.9 11.5 6c42 21.9 68.6 56.7 72.9 95.8 8 75.9-66.6 146.5-166.6 157.7z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@ -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;
}

@ -10,9 +10,16 @@
* @param {Array<HTMLElement|string>} 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@ -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 {
}

@ -2,67 +2,75 @@
<html>
<head>
<meta charset="utf-8">
<title>nostr sandbox</title>
<title>nostr</title>
<link rel="stylesheet" href="main.css" type="text/css">
</head>
<body>
<div class="tabs">
<main class="tabbed">
<input type="radio" name="maintabs" id="feed" class="tab" checked>
<label for="feed">feed</label>
<!-- <input type="radio" name="maintabs" id="trending" class="tab">
<label for="trending">trending</label>
<input type="radio" name="maintabs" id="direct" class="tab">
<label for="direct">direct</label>
<input type="radio" name="maintabs" id="chat" class="tab">
<label for="chat">chat</label> -->
<input type="radio" name="maintabs" id="settings" class="tab">
<label for="settings">profile</label>
<div class="tab">
<input type="radio" name="maintabs" id="homefeed" checked>
<label for="homefeed">feed</label>
<div class="content">
<div class="cards" id="feedlist"></div>
<div class="tabs">
<div class="tab-content">
<article class="mbox">
<img class="mbox-img" id="bubble" src="assets/comment.svg" alt="">
<div class="mbox-body" id="newMessage">
<form action="#" class="form-inline" id="writeForm">
<input type="text" name="message">
<button type="submit" id="publish" disabled>send</button>
</form>
<small id="sendstatus" class="form-status"></small>
</div>
</article>
<div class="cards" id="homefeed"></div>
</div>
</div>
<div class="tab">
<input type="radio" name="maintabs" id="trending">
<label for="trending">trending</label>
<div class="content">
<div class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/12.md">NIP-12 (generic queries)</a></p>
</div>
</div>
<div class="tab">
<input type="radio" name="maintabs" id="direct">
<label for="direct">direct</label>
<div class="content">
<div class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/04.md">NIP-04 (direct msg)</a></p>
</div>
</div>
<div class="tab">
<input type="radio" name="maintabs" id="chat">
<label for="chat">chat</label>
<div class="content">
<div class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/28.md">NIP-28 (public chat)</a></p>
</div>
</div>
<div class="tab">
<input type="radio" name="maintabs" id="settings">
<label for="settings">settings</label>
<form name="settings" method="post" class="content">
<label for="pubkey">public-key</label>
<input type="text" name="pubkey" id="pubkey">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="button-inline" >
<small>show</small>
</button>
</label>
<input type="password" name="privatekey" id="privatekey">
<div class="buttons">
<div id="keystatus" class="form-status" hidden></div>
<button type="button" name="generate" tabindex="0">new</button>
<span class="inline-text"></span>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
<div class="tab-content">
<!-- <div class="form form-inline">
<input type="text" name="username" id="username" placeholder="username">
<button type="button" name="publish-username" tabindex="0">publish</button>
</div> -->
<form action="#" name="settings">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="btn-inline" >
<small>show</small>
</button>
</label>
<input type="password" id="privatekey">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<span class="inline-text"></span>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
</div>
</div>
</main>
</div>
</body>
<script src="main.js"></script>
</html>

@ -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;
}

@ -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;
}
});

@ -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 {
}
*/

@ -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);
};
};
Loading…
Cancel
Save