Compare commits

...

108 Commits

Author SHA1 Message Date
OFF0 d1923d6784
0.0.31
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 bb8790e949
timeline: add route to show timeline of other users
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
added experimental /timeline/npub... route, this currently does
not correctly reload as contacts of that npub are lost onload.
other than that it seems to work well, but needs more testing.
1 year ago
OFF0 4e5bf50e54
cleanup: remove console log 1 year ago
OFF0 fea8c0bd21
route: add contact list view
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
added new route /contacts/npub... to show contact lists of users.
each user has about text, follow/unfollow buttons.

fixed CSS and JavaScript links in index.html to support deeper
path i.e. /contacts/npub... uri's.
1 year ago
OFF0 25d3283a80
routes: fix internal link detection
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details
it just checked link length, but should at least ensure that the
condition which checks for internal links starts with a slash.
1 year ago
OFF0 80d3a3f6a7
view: prevent scrolling navigation elements
scrolling on navigation moved the whole view up, this change
prevents nav from being scrollable.
1 year ago
OFF0 edf5ac21a6
relays: remove noisy eose console log
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details
1 year ago
OFF0 889a49e0ef
links: remove trailing slash in link text
due to URL formatting, the link text of https://example.com was
showing as example.com/

removed trailing slash if possible.
1 year ago
OFF0 7539d11a56
contact: show timeline of only followed contacts
added home and global feed, home will try to show timeline with
all followed contacts and fallback to global if there are no
followees.

in a future commit global tab could become search and have a
search field at the top.
1 year ago
OFF0 f92cfbbb31
contact: add follow and unfollow support
this creates a kind 3 event that includes a list of profiles that
the user is following.

the feed is still the public global feed and individual feed with
only events from followed pubkeys will be added in the next commit.

also:
- updated following and unfollwing wording
- added proper primary and secondary button styles.
1 year ago
OFF0 208ea6363a
profile: refactor
each view has it's own DOMMap to reference its own elements this
can us non-unique keys, i.e. each view can have a header key.

changed:
- use getViewElem instead of querying the dom
- access button.dataset.id directly before traversing the dom
1 year ago
OFF0 636c4610de
event: align content with definition term 1 year ago
OFF0 b2dc778eeb
error: overlay should spawn over the whole screen 1 year ago
OFF0 052b35155e
feed: subtile vertical rythm improvement 1 year ago
OFF0 a2cf5c90b9
profile: fix displaying website metadata
was using the wrong key and did not update
1 year ago
OFF0 f9fc0162ff
event: remove time in title attribute
time is already shown as an entry in the content area
1 year ago
OFF0 5a31d78a07
content: fix parse links with uppercase characters
ignore case sensitivity when checking for possible links
1 year ago
OFF0 a73af8bb78
event: fix classname on event detail view
still had recommend-server class (copy/paste error)
1 year ago
OFF0 f3478c4148
0.0.30
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 659e641af3
feed: change username to look bold
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 60510411a6
view: document view and template functions
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 da3c1b02b2
view: separate view logic from template code
moved code that creates the view dom elements into its own module.

deliberately not in ui.ts as view.ts is imported early and has
almost no dependecies except for nostr-tools and ./utils
1 year ago
OFF0 5039b3dece
profile: show following, contact-list changes and contact events
- added following count in profile header
- added contact-list changes events
- added new raw event detail view to visulaize event metadata and
  raw content
1 year ago
OFF0 027c61e00f
feed: support nostr:npub uri scheme (part of nip-21)
added support for nostr:npub links in textnotes content.
1 year ago
OFF0 38591a3b92
feed: fix nesting layout bug
under some circumstances the reply-to lines on 3rd level did not
correctly visualize the nesting of the thread.

fixed and added links to test cases.
1 year ago
OFF0 e722f74665
profile: keep track of replies
before the map that keeps track of all replies-to ids was not in
the same scope of the subscription but genereated in each callback.
1 year ago
OFF0 ea21e47cad
datetime: user browser lang for date formatting 1 year ago
OFF0 5ef45d93d3
nav: add history entry only once onload
reloading the page always added a history entry breaking back
button functionality.

fixed by only adding a history entry on load if the history is
empty, so that reloads will not add the same entry over and over
again.
1 year ago
OFF0 cd7dfa3f19
profiles: improve profile view
- add about to profile header
- set document title to profile name
- refactor how profile metadata
1 year ago
OFF0 3a1bf6f56f
feed: navigate by clicking on profile image and content
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
before npub and note links was only supported on profile name and
date/time.

added support to click on profile image and note content (unless
a link was clicked).
1 year ago
OFF0 543d327b5c
feed: ignore shift click
the global click callback should ignore shift click which allows
to use native browser behavior (open link in new window).
1 year ago
OFF0 cc7e8015cb
feed: update profile name color
profile name in darkmode is lightgray but in lightbox was blue.

changed to lightgray for consistency.
1 year ago
OFF0 e566447b0a
0.0.29
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 36ba582dd4
feed: update feed colors and spacing
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
- font: use regular font and smaller font size on mobile
- made publish button smaller on mobile
- add borders between notes and increase card spacing
- changed background colors of cards and nav
1 year ago
OFF0 5e61dcef0c
feed: subscribe to pow events
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
if difficulty-filter is set the subscription of the global feed
can filter only ids with 0-prefix to save bandwidth.

as there may not be many pow events within the last 24h, the
date range is now only enabled if there is no diffculty-filter
set so that the feed is not empty.

if this works as expected it could also only subscripe to
reactions and profile info with pow ids.

deps: update nostr-tools to 1.10.1

This version supports subscribing to prefixed ids, see
3bdb68020d
1 year ago
OFF0 cb04e2c6b7
0.0.28
ci/woodpecker/push/woodpecker Pipeline was successful Details
1 year ago
OFF0 86ac5e47fa
feed: fix too many subscriptions
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
fetch profile meta data only for future notes for pubkeys that are
not known of if the event kind is a text note, but not for reactions
or meta data.

this drastically reduces one time subscriptions.
1 year ago
OFF0 b67f59ebec
noxy: disable images and link previews
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
noxy is not running at the moment and was enabled for events
with enough POW.

temporary disabled noxy link previews and images.
1 year ago
OFF0 9036f6a073
0.0.27
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 abf0ae7249
0.0.26
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 30809ccbce
feed: update subscriptions
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
playing around with subscriptions, feed now loads profile names,
profile views also show replied to notes and notes recursively
search for replies or replies.

this commit does too many subscriptions, ideally a max of three
subscriptions are done at once. in future commit it would be nice
to subscribe modularly, have done callbacks or push into subscribe
next queues.
2 years ago
OFF0 7113bf1a4d
feed: visually improve threads styling
CSS is a bit messy and should be redone, but threads are now styled
a bit as branches.
2 years ago
OFF0 6910a4da4e
0.0.25
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 683b500121
cleanup
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
- remove X icon placeholder
- remove weird unicode whitespace
- delete comment out code
2 years ago
OFF0 c88cfa74bb
refactor: improve view and move code to ui and notes
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details
cleanup code and move parts to ui.ts and notes.ts.

simplify view and fix some weird animation issue, it should run
pretty stable now.

updated color and spacings.

profile view now showing kind 0 name, but it is unnecessarily
re-rendering. this part should probably go to a custom profil
subscription callback in the future. keeping as is for now and
refactor later.
2 years ago
OFF0 42fbd7c4c8
feed: ignore ctrl click
ctrl/command click should do the native browser thing and not be
intercepted as normal click, i.e. ctrl click open-in-new-tab works
2 years ago
OFF0 77711d655d
feed: ignore vmess protocol messages
Drop encrypted vmess messages.
2 years ago
OFF0 2fa7cce511
about: fix styling 2 years ago
OFF0 aec72b6c62
refactor: function to es6 arrow functions
so that it doesnt depend on function hoisting.
2 years ago
OFF0 bbfa4ae545
main: fix global click handler
settings view and write new message didnt show. reason was typescript
expected an instance of an HTMLElement but this didnt allow for
SVG elements inside the write button. Another reason was that the
condition expected a parent with data-id which isn't the case for
settings button nor write-new-message.
2 years ago
OFF0 f9fe892937
main: convert remaining main to typescript 2 years ago
OFF0 e1ba0b4c6f
profile: move and type profile metadata 2 years ago
OFF0 ab1ea2fa41
view: fix hidden settings view
css regression, somehow flex order doesnt overlay on the main
views, fixed with z-index.
2 years ago
OFF0 78588ec1c7
media: move noxy preview link fetch logic to media.ts 2 years ago
OFF0 7abd6fdc6e
nav: change from data-nav to simple href check 2 years ago
OFF0 976ea21d52
write: move reply and write-new-text note to write.ts 2 years ago
OFF0 9a34d4f31e
refactor: global click listener and cleanup
breaking up the global click callback to make it easier in the future
to move some parts into ui modules such as settings.ts.
2 years ago
OFF0 52e2a31421
reactions: move reaction logic to typed module 2 years ago
OFF0 43754149a9
settings: move remaining settings code
with this change everything related to user settings is now
in settings.ts module.
2 years ago
OFF0 efda7737c8
utils: move updateelemheight to dom utils 2 years ago
OFF0 ca4594a7e7
settings: move mining difficulty, filter and timeout configs
type and move mining related configs to settings.ts.
2 years ago
OFF0 b44fe10870
system: move pow function and error overlay to system
type and move powEvent and its error overlay to system.ts
2 years ago
OFF0 d654028a86
settings: refactor pubkey into config.pubkey global
starting to move global application user config to settings.ts.
plan is to only share user settings via the config object, with
this all settings related ui can be moved out of main.js into its
own module.
2 years ago
OFF0 cadd0302a5
utils: cleanup and move isvalidurl to utils/url 2 years ago
OFF0 23188c161f
utils: move lock and unlock scroll functions to utils 2 years ago
OFF0 70fb0da35a
utils: import directly from utils
it is not worth to import everything from utils, as there are too
many functions, better import them directly from each module.
2 years ago
OFF0 495e755844
styles: move stylesheets to styles 2 years ago
OFF0 33dd40bae5
typescript 2 years ago
OFF0 6e404eac6b
refactor: type element attributes
use attributes of html element type.

so that the following example is correctly typed:

elem('input', {
  className: 'foo',
  hidden: false,
  onclick: () => alert('hi'),
  tabIndex: 1,
  valueAsNumber: 1,
});

but this fails as foo is no valid attribute on div element:

elem('div', {foo: 'bar'});
2 years ago
OFF0 489a260427
refactor: type elem and enforce inferred generic type
typed elem so that it returns the exact type of the HTMLElement,
and that name must be a key of HTMLElementTagNameMap.

example:

elem('form'); // returns HTMLFormElement

elem('abc'); // not assignable to parameter of type 'keyof HTMLElementTagNameMap'
2 years ago
OFF0 2d46687e12
refactor: type events.ts, url.ts and crypto.ts 2 years ago
OFF0 fa97027321
refactor: type view.ts, dom.ts and time.ts 2 years ago
OFF0 35b8baef92
refactor: type subscriptions.ts
typed subscribe functions in subscriptions.ts
2 years ago
OFF0 309367852a
routes: use nip19 as routes
so we need no guessing logic and know what to subscribe too. in a
later step it add more to the view i.e. show profile meta data for
npub.
2 years ago
OFF0 b0e190fd22
nip19: use bech32 npub and note in uris
convert pubkey to npub and event id to note and store for
later. use npub and note for uri's so that we dont need to
guess what the view of a specific uri has to render.

this is only the first step that changes uri's, next step is
for the view to react to npub or note and render the
correct view accordingly.
2 years ago
OFF0 87cd5f21b3
relays: type and upgrade to nostr-tools@1.6.0
move and typed relay related code to relays.ts

upgrade nostr-tools to latest greatest, major version with
breaking changes:

- relayPool is gone in favor of SimplePool, but this commit just
  used relayInit directly as relays should become configurable at
  some point
2 years ago
OFF0 57be701ef9
routes: use generic view containers
proof of concept to use generic view containers instead of specifc
functions to show and hide particular views.

a view has an identifier (path) which is used to subscribe to
relevant data. changing a view updates the history so that browser
back displays the last view. each view container has its own
scrollbar so that the scrolling position should be preserved when
changing back and forth between different views.

this change also removes CSS tabs in favor of view or overlays
such settings or write a new text note.

profile and notes deeplink use now native HTML anchors to improve
accessibility (copy/paste, open-in-new-tab, search engines).
2 years ago
OFF0 784b9d9ea0
0.0.24
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 46bd581950
replay: temp disable overloaded relays
ci/woodpecker/push/woodpecker Pipeline was successful Details
there is currently lots of chinese spam, probably since damus was
accepted in appstore.

temporary disabled damus, snort.social and nostr.info relays, and
added some less known german and swiss relays.
2 years ago
OFF0 9e46342250
0.0.23
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 e84af81b97
relay: add snort.social relay
ci/woodpecker/push/woodpecker Pipeline was successful Details
relay.snort.social seems to be a popular relay, adding a new one
to the hardcoded list of relays, this should be configurable in
the future.

related damus relay is often not responding or slow, but keeping
it for now.
2 years ago
OFF0 5a18e2952c
profile: increase number of notes shown on profile
ci/woodpecker/push/woodpecker Pipeline was successful Details
there is no good reason to show less notes on a profile deeplink.
changed limit to 450, same as explore feed.
2 years ago
OFF0 593d4c6e38
relay: change wlvs.space to eden.land
ci/woodpecker/push/woodpecker Pipeline was successful Details
wss://nostr-relay.wlvs.space is not working anymore, seems they
moved, see:
- https://nostr.ch/cbb15cbd3a36a79a4611ee2852bea503f764c4d48fdb5988b1124a146efccc55
2 years ago
OFF0 4544ccc996
0.0.22
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 339e1ed97c
feed: style 3 levels of nested threads
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
instead of just adding 1 line for all replies to point to the
original note, using smaller profile pic for replies of replies
help to understand the context of the replies.

this is done without increasing indentation as heavy nesting can
mess up the layout so only indentation is visible and the space
for the actual content gets to small.

if needed users can inspect replies and navigate to the deeplink
to see how the next 3 levels are nested.
2 years ago
OFF0 e94c9c92da
feed: sort replies by created at
replies were not sorted correctly, reason for this was that an
array of dom elements was sorted, but instead it should sort the
notes.

regression introduced in:
- 2e40a273c4
2 years ago
OFF0 0dcfa6e0a9
feed: show publish note within the same second
a fast relay might return a newly published note within the same
second, but nostrweb only shows notes that are older than now.

was introduced in 4a68940681
2 years ago
OFF0 f4f951469f
feed: less eager rendering
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
before this change every new incoming text note called a render,
that filters, sorts and iterates all known text notes and creates
missing dom elements and appends into the right place.

this change throttles and debounces (both!) the render function,
that less checks have to be performed, especially on page load
when potentially 100s of events arrive within a short time.

it is important to throttle and debounce, else either the last call
is missed or no render is called while events are being received.

this change surfaced an error in recommend server that depended on
all known text notes already being rendered and inside the dom.
this function should probably be handled by render feed itself.
2 years ago
OFF0 dffcbc6b2b
0.0.21
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 2e40a273c4
nip-10: fix duplicate replies in feed
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
some replies rendered twice in different positions, seems to be
related to deprecated positional event tags and a regression
introduced in a596121821

on receiving events it analizes the event tags and stores the id
of the replied event so the client can easily search for replies
later. marked tags are prefered with a fallback to positional tags
as described in nip-10.

mentions are ignored at the moment.

example event that had some replies rendered twice:
22e4ea80161ac591059da611d3ab63c583cb1d47a706826db2fc6955ac0a70b5
2 years ago
OFF0 ff4b67a0fb
0.0.20
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 c16ccdb4e3
nip-13: improve mining error
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
- fix error overlay colors in light-mode
- improve error text, mentoning what happend and what to do
2 years ago
OFF0 98aa92b9ed
0.0.19
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 a61b54de7c
feed: fix link rendering
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
some words were incorrectly detected as links.

changed:
- split words by any whitespace character, not just char 32
- match words _beginning_ with http, https or www.

example of broken link user white space character code 160, see:
feb290999a2746bf0914ede8a811b52eccd423f9f5373232e4f5947131fb47aa
2 years ago
OFF0 8ff1d2c1d8
nip-13: check zero leading bits
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
before it was only testing if the nonce commitment is high enough
but didn't verify leading zero bits.

with this change rendering checks now the leading zero bits count
of the event id.
2 years ago
OFF0 4a68940681
feed: render only notes from the past
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
some notes have a created at field that is 1 year in the future,
these are always shown on to of the feed. changed to only render
notes that are from the past.
2 years ago
OFF0 c2db08c7dc
0.0.18
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 caf5083caa
nip-13: difficulty filter
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
added a slider to adjust the difficulty filter, current default is
0 so all notes are rendered. increasing the filter will hide notes
with lower difficulty target.

changed the max difficulty from 256 to 32 for now so that the
range slider is usable.

this does not yet hide reactions with lower difficulty.
2 years ago
OFF0 a3de8f1595
feed: improve recommend server (kind 2) messages
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
- check that event.content is a valid WSS url, drop otherwise
- fix bug that rendered the same server recommendation twice
2 years ago
OFF0 2dd9cf633a
0.0.17
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 37f0a07cf3
nip-13: show working msg and cancel btn while mining
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
mining often takes a few seconds. it can be confusing if nothing
happens when a user is publishing their profile, upvoting a note
or posting a new note.

added visual feedback that nostrweb is working with an option to
cancel the mining process.
2 years ago
OFF0 898e7265c2
nip-13: add settings for mining difficulty and timeout
adding settings to change mining difficulty and timeout, so users
can change or disable pow. also added some explanation and link
to nip-13.

setting arbitrary low default to 16 zero mining difficulty and
5 seconds timeout.
2 years ago
OFF0 a596121821
feed: dirty fix to show replies
now that nonce tag is always the first element in the tags list,
a bug surfaced that replies from nostrweb did not render anymore.

reason was that the code expected the first tag to be an e tag and
took its reply-id. this commit is a quick fix that takes the first
reply-id from the first e tag.

the proper way is a bit more complicated as nip-10 defines a
preferred and deprecated way.

this is a quick and dirty fix so that replies work with nip-13 pow
events, but nip-10 event tags should be properly supported but in
a later commit.
2 years ago
OFF0 d5e9ef18c7
nip-13: add timeout and show user facing error if it exceeds
mining may take a long time if the mining difficulty is high.

calculating pow for text notes, upvotes and profile meta
data now has a timeout of 10s. if the timeout exceeds a user
facing error is shown with the option to try again.

the error is currently very basic, and only displays timeout -
something went wrong, cancel and try again button.
2 years ago
OFF0 a1b1f3baee
nip-13: mine pow async in worker any only invoke noxy with pow
added pow to text notes, reactions and metadata events. pow is
mined async in a worker so that the main process does not freeze.

noxy profile images, link and image previews are now now only
invoked if an event has some valid work proof. noxy can decide
if there is enough work and whether or not to serve data for a
certain event.

target difficulty can be implemented in a later step, this change
only check if there is any valid nonce tag with commitment target
greater than 0.
2 years ago
OFF0 7edf1151a6
nip-25: use proper reacted to e and p tags
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
the actual event that is being reacted must be the last event tag,
was wrongly using the first one and displayed wrong star if tags
had more than 1 event tag.

upvoting now also includes e and p tags from the event that is
upvoted.

see also https://github.com/nostr-protocol/nips/blob/master/25.md
2 years ago
OFF0 dc2def3361
0.0.16
ci/woodpecker/push/woodpecker Pipeline was successful Details
2 years ago
OFF0 485510314a
relays: update list of relays
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
adding damus again (was disabled when it was down and heavy spam
happened last year), remove nostr.sandwich.farm as it currently
errors with '502 Bad Gateway'.
2 years ago

@ -17,9 +17,10 @@ export const options = {
'src/assets/star-fill.svg', 'src/assets/star-fill.svg',
'src/favicon.ico', 'src/favicon.ico',
'src/index.html', 'src/index.html',
'src/main.css', 'src/styles/main.css',
'src/main.js', 'src/main.ts',
'src/manifest.json', 'src/manifest.json',
'src/worker.js',
], ],
outdir: 'dist', outdir: 'dist',
//entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths

1225
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{ {
"name": "nostrweb", "name": "nostrweb",
"version": "0.0.15", "version": "0.0.31",
"private": true, "private": true,
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
@ -8,7 +8,10 @@
"esbuild": "^0.14.54", "esbuild": "^0.14.54",
"esbuild-plugin-alias": "^0.2.1", "esbuild-plugin-alias": "^0.2.1",
"events": "^3.3.0", "events": "^3.3.0",
"nostr-tools": "0.24.1" "readable-stream": "4.3.0"
},
"dependencies": {
"nostr-tools": "1.10.1"
}, },
"scripts": { "scripts": {
"build": "node tools/build.js", "build": "node tools/build.js",

@ -4,23 +4,25 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>about / nostr</title> <title>about / nostr</title>
<link rel="stylesheet" href="main.css" type="text/css"> <link rel="stylesheet" href="styles/main.css" type="text/css">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<main class="text"> <main>
<h1>nostr: notes and other stuff transmitted by relays</h1> <div class="text">
this is a nostr web client.<br> <h1>nostr: notes and other stuff transmitted by relays</h1>
source code is at <a href="https://git.qcode.ch/nostr/nostrweb">git.qcode.ch/nostr/nostrweb</a>. this is a nostr web client.<br>
<p> source code is at <a href="https://git.qcode.ch/nostr/nostrweb">git.qcode.ch/nostr/nostrweb</a>.
you are looking at version #[PKG_VERSION]#, built at git commit <p>
<a href="https://git.qcode.ch/nostr/nostrweb/commit/#[GIT_COMMIT]#">#[GIT_COMMIT]#</a>. you are looking at version #[PKG_VERSION]#, built at git commit
</p> <a href="https://git.qcode.ch/nostr/nostrweb/commit/#[GIT_COMMIT]#">#[GIT_COMMIT]#</a>.
<p> </p>
for more information about nostr protocol, check out <p>
<a href="https://github.com/nostr-protocol/nostr#readme" target="_blank" rel="noopener noreferrer">github.com/nostr-protocol/nostr#readme</a>. for more information about nostr protocol, check out
</p> <a href="https://github.com/nostr-protocol/nostr#readme" target="_blank" rel="noopener noreferrer">github.com/nostr-protocol/nostr#readme</a>.
back to <a href="/">nostr.ch</a> </p>
back to <a href="/">nostr.ch</a>
</div>
</main> </main>
</body> </body>
</html> </html>

@ -1,163 +0,0 @@
/* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */
.mbox {
--profileimg-size: 4rem;
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 1rem;
padding: 0 var(--gap);
}
@media (orientation: portrait) {
.mbox {
padding: 0 calc(.5 * var(--gap));
}
}
.mbox:last-child {
margin-bottom: 0;
}
.mbox .mbox {
padding: 0;
}
.mbox-img {
align-self: start;
background-color: var(--bgcolor-textinput);
border-radius: var(--profileimg-size);
border: 1px solid transparent;
flex-basis: var(--profileimg-size);
height: var(--profileimg-size);
margin-right: 1rem;
/* padding-top: .5ch; */
max-height: var(--profileimg-size);
max-width: var(--profileimg-size);
outline: .5rem solid var(--bgcolor);
overflow: hidden;
position: relative;
z-index: 2;
}
.mbox-img canvas,
.mbox-img img {
display: block;
}
.mbox-updated-contact .mbox-img,
.mbox-recommend-server .mbox-img {
--profileimg-size: 2rem;
margin-left: 2rem;
}
.mbox-body {
flex-grow: 0;
flex-shrink: 1;
word-break: break-word;
}
.mbox-img + .mbox-body {
flex-basis: calc(100% - 64px - 1rem);
}
.mbox-header {
flex-basis: calc(100% - 64px - 1rem);
flex-grow: 0;
flex-shrink: 1;
margin-top: 0;
}
.mbox-header time,
.mbox-username {
color: var(--color-accent);
cursor: pointer;
}
.mbox-kind0-name {
color: var(--color);
}
.mbox-updated-contact .mbox-body,
.mbox-recommend-server .mbox-body {
display: block;
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;
}
.mbox {
overflow: hidden;
}
.mbox .mbox {
overflow: visible;
position: relative;
}
.mobx-replies {
flex-grow: 1;
position: relative;
}
.mbox .mbox::before,
.mobx-replies::before {
background-color: var(--bgcolor-inactive);
border: none;
content: "";
display: block;
height: 100vh;
left: calc(.5 * var(--profileimg-size));
margin-left: -.2rem;
position: absolute;
top: -100vh;
width: .4rem;
}
[data-append]::after {
color: var(--color-accent);
content: "…";
}
.preview-loaded a {
background-color: var(--bgcolor-textinput);
border: 1px solid var(--bgcolor-inactive);
color: var(--color);
display: flex;
flex-direction: column;
margin: var(--gap) 0 0 0;
max-width: 48rem;
padding: 1.5rem 1.8rem;
text-decoration: none;
}
.preview-loaded a:visited {
color: inherit;
}
.preview-title {
font-size: inherit;
margin: 0;
}
.preview-descr {
font-size: var(--font-small);
}
.preview-image {
background-color: rgba(72, 63, 63, 0.07);
margin-bottom: var(--gap);
max-height: 30vh;
object-fit: contain;
}
.preview-image-only {
background-color: var(--bgcolor-textinput);
border: 1px solid var(--bgcolor-inactive);
margin: var(--gap) 0 0 0;
max-width: 48rem;
padding: 1.5rem 1.8rem;
width: 100%;
/* TODO: revert when things calm down or we find an alternative */
display: none;
}

@ -0,0 +1,291 @@
import {Event, nip19, signEvent} from 'nostr-tools';
import {elem} from './utils/dom';
import {dateTime} from './utils/time';
import {isNotNonceTag, isPTag} from './events';
import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {getMetadata} from './profiles';
import {publish} from './relays';
import {parseJSON} from './media';
const contactHistoryMap: {
[pubkey: string]: Event[];
} = {};
const hasOwnContactList = () => {
return !!contactHistoryMap[config.pubkey];
};
/**
* returns true if user is following pubkey
*/
export const isFollowing = (pubkey: string) => {
const following = contactHistoryMap[config.pubkey]?.at(0);
if (!following) {
return false;
}
return following.tags.some(([tag, value]) => tag === 'p' && value === pubkey);
};
export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem(`followBtn-${pubkey}`);
const view = getViewOptions();
if (followBtn && (view.type === 'contacts' || view.type === 'profile')) {
const hasContact = isFollowing(pubkey);
const isMe = config.pubkey === pubkey;
followBtn.textContent = isMe ? 'following' : hasContact ? 'unfollow' : 'follow';
followBtn.classList.remove('primary', 'secondary');
followBtn.classList.add(hasContact ? 'secondary' : 'primary');
followBtn.hidden = false;
}
};
const updateFollowing = (evt: Event) => {
const view = getViewOptions();
if (evt.pubkey === config.pubkey) {
localStorage.setItem('follwing', JSON.stringify(evt));
}
switch(view.type) {
case 'contacts':
if (hasOwnContactList()) {
const lastContactList = contactHistoryMap[config.pubkey]?.at(1);
if (lastContactList) {
const [added, removed] = findChanges(evt, lastContactList);
[
...added.map(([, pubkey]) => pubkey),
...removed.map(([, pubkey]) => pubkey),
].forEach(updateFollowBtn);
} else {
evt.tags
.filter(isPTag)
.forEach(([, pubkey]) => updateFollowBtn(pubkey));
}
}
break;
case 'profile':
updateFollowBtn(view.id);
if (view.id === evt.pubkey) {
const npub = nip19.npubEncode(evt.pubkey);
// update following link
const following = getViewElem('following') as HTMLElement;
if (following) {
const count = evt.tags.filter(isPTag).length;
const anchor = elem('a', {
data: {following: evt.pubkey},
href: `/contacts/${npub}`,
title: dateTime.format(evt.created_at * 1000),
}, [
'following ',
elem('span', {className: 'highlight'}, count),
]);
following.replaceWith(anchor);
setViewElem('following', anchor);
}
let timeline = getViewElem('timeline');
if (!timeline) {
timeline = elem('a', {href: `/timeline/${npub}`}, 'timeline');
getViewElem('header').querySelector('footer')?.append(timeline);
setViewElem('timeline', timeline);
}
}
break;
}
};
export const refreshFollowing = (id: string) => {
if (contactHistoryMap[id]?.at(0)) {
updateFollowing(contactHistoryMap[id][0]);
}
};
export const setContactList = (evt: Event) => {
const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt);
return;
}
if (contactHistory.find(({id}) => id === evt.id)) {
return;
}
contactHistory.unshift(evt);
updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
};
/**
* findChanges
* returns added and removed contacts list of P tags, ignores any tag other than 'p'
*/
const findChanges = (current: Event, previous: Event) => {
const previousContacts = previous.tags.join('\n'); // filter for p tags first?
const currentContacts = current.tags.join('\n');
const addedContacts = current.tags.filter(([tag, pubkey]) => tag === 'p' && !previousContacts.includes(pubkey));
const removedContacts = previous.tags.filter(([tag, pubkey]) => tag === 'p' && !currentContacts.includes(pubkey));
return [addedContacts, removedContacts];
};
export const resetContactList = (pubkey: string) => {
delete contactHistoryMap[pubkey];
};
export const getContactUpdateMessage = (
addedList: string[][],
removedList: string[][],
) => {
const content = [];
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
content.push(
'follows ',
elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName),
);
}
if (addedList.length > 1) {
content.push(` (+ ${addedList.length - 1} others)`);
}
if (removedList?.length > 0) {
if (content.length) {
content.push(' and');
}
content.push(' unfollowed ');
if (removedList.length > 1) {
content.push(`${removedList.length}`);
} else {
const removedPubkey = removedList[0][1];
const {userName: removeduserName} = getMetadata(removedPubkey);
const removedNpub = nip19.npubEncode(removedPubkey);
content.push(elem('a', {href: `/${removedNpub}`, data: {profile: removedPubkey}}, removeduserName));
}
}
return content;
};
export const updateContactList = (evt: Event) => {
const contactHistory = contactHistoryMap[evt.pubkey];
if (contactHistory.length === 1) {
return [contactHistory[0].tags.filter(isPTag)];
}
const pos = contactHistory.findIndex(({id}) => id === evt.id);
if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update
return findChanges(evt, contactHistory[pos + 1]);
}
// update existing contact entries
contactHistory
.slice(0, -1)
.forEach((entry, i) => {
const previous = contactHistory[i + 1];
const [added, removed] = findChanges(entry, previous);
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
const updated = getContactUpdateMessage(added, removed);
contactNote?.replaceChildren(...updated);
});
return [evt.tags.filter(isPTag)];
};
/**
* returns list of pubkeys the given pubkey is following
* @param pubkey
* @returns {String[]} pubkeys
*/
export const getContacts = (pubkey: string) => {
const following = contactHistoryMap[pubkey]?.at(0);
if (!following) {
return [];
}
return following.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
};
/**
* returns list of pubkeys the user is following, if none found it will try from localstorage
* @returns {String[]} pubkeys
*/
export const getOwnContacts = () => {
const following = getContacts(config.pubkey);
if (following.length) {
return following;
}
const followingFromStorage = localStorage.getItem('follwing');
if (followingFromStorage) {
const follwingData = parseJSON(followingFromStorage) as Event;
// TODO: ensure signature matches
if (follwingData && follwingData.pubkey === config.pubkey) {
return follwingData.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
}
}
return [];
};
const updateContactTags = (
followeeID: string,
currentContactList: Event | undefined,
) => {
if (!currentContactList?.tags) {
return [['p', followeeID], ['p', config.pubkey]];
}
if (currentContactList.tags.some(([tag, id]) => tag === 'p' && id === followeeID)) {
return currentContactList.tags
.filter(([tag, id]) => tag === 'p' && id !== followeeID);
}
return [
['p', followeeID],
...currentContactList.tags
.filter(isNotNonceTag),
];
};
export const followContact = async (pubkey: string) => {
const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement;
const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement;
if (!followBtn || !statusElem) {
return;
}
const following = contactHistoryMap[config.pubkey]?.at(0);
const unsignedEvent = {
kind: 3,
pubkey: config.pubkey,
content: '',
tags: updateContactTags(pubkey, following),
created_at: Math.floor(Date.now() * 0.001),
};
followBtn.disabled = true;
const newContactListEvent = await powEvent(unsignedEvent, {
difficulty: config.difficulty,
statusElem,
timeout: config.timeout,
}).catch(console.warn);
if (!newContactListEvent) {
statusElem.textContent = '';
statusElem.hidden = false;
followBtn.disabled = false;
return;
}
const privatekey = localStorage.getItem('private_key');
if (!privatekey) {
statusElem.textContent = 'no private key to sign';
statusElem.hidden = false;
followBtn.disabled = false;
return;
}
const sig = signEvent(newContactListEvent, privatekey);
// TODO: validateEvent?
if (sig) {
statusElem.textContent = 'publishing…';
publish({...newContactListEvent, sig}, (relay, error) => {
if (error) {
return console.error(error, relay);
}
statusElem.hidden = true;
followBtn.disabled = false;
console.info(`event published by ${relay}`);
});
}
};

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

@ -0,0 +1,64 @@
import {Event} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto';
export const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
export const isPTag = ([tag]: string[]) => tag === 'p';
export const hasEventTag = (tag: string[]) => tag[0] === 'e';
export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce';
/**
* validate proof-of-work of a nostr event per nip-13.
* the validation always requires difficulty commitment in the nonce tag.
*
* @param {EventObj} evt event to validate
* TODO: @param {number} targetDifficulty target proof-of-work difficulty
*/
export const validatePow = (evt: Event) => {
const tag = evt.tags.find(tag => tag[0] === 'nonce');
if (!tag) {
return false;
}
const difficultyCommitment = Number(tag[2]);
if (!difficultyCommitment || Number.isNaN(difficultyCommitment)) {
return false;
}
return zeroLeadingBitsCount(evt.id) >= difficultyCommitment;
}
export const sortByCreatedAt = (evt1: Event, evt2: Event) => {
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;
};
export const sortEventCreatedAt = (created_at: number) => (
{created_at: a}: Event,
{created_at: b}: Event,
) => (
Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1
);
const isReply = ([tag, , , marker]: string[]) => tag === 'e' && marker !== 'mention';
/**
* find reply-to ID according to nip-10, find marked reply or root tag or
* fallback to positional (last) e tag or return null
* @param {event} evt
* @returns replyToID | null
*/
export const getReplyTo = (evt: Event): string | null => {
const eventTags = evt.tags.filter(isReply);
const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply');
if (withReplyMarker.length === 1) {
return withReplyMarker[0][1];
}
const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root');
if (withReplyMarker.length === 0 && withRootMarker.length === 1) {
return withRootMarker[0][1];
}
// fallback to deprecated positional 'e' tags (nip-10)
const lastTag = eventTags.at(-1);
return lastTag ? lastTag[1] : null;
};

@ -2,112 +2,113 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>nostr</title> <title>nostr</title>
<link rel="stylesheet" href="main.css" type="text/css"> <link rel="stylesheet" href="/styles/main.css" type="text/css">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<main class="tabbed"> <div class="root">
<input type="radio" name="maintabs" id="settings" class="tab"> <main>
<label for="settings">profile</label> <aside>
<input type="radio" name="maintabs" id="feed" class="tab" checked> <button name="new-note" id="bubble">
<label for="feed">feed</label> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031">
<!-- <input type="radio" name="maintabs" id="trending" class="tab"> <path fill="var(--bgcolor-textinput)" stroke="darkmagenta" stroke-width="4" 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"/>
<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> -->
<div class="tabs">
<div class="tab-content">
<artcile>
<svg id="bubble" xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031">
<path fill="var(--bgcolor-textinput)" stroke="currentColor" stroke-width="2" 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> </svg>
<div id="newMessage" hidden> </button>
<form action="#" id="writeForm" class="form-inline"> <section class="view" id="newNote" hidden>
<fieldset> <form action="#" id="writeForm" class="form-inline">
<legend>write a new note</legend> <fieldset>
<textarea name="message" rows="1"></textarea> <legend>write a new note</legend>
<div class="buttons"> <textarea name="message" rows="1"></textarea>
<button type="submit" id="publish" disabled>send</button> <div class="buttons">
<button type="button" name="back">back</button> <button type="submit" id="publish" class="primary" disabled>send</button>
</div> <button type="button" name="back" class="primary">back</button>
<small id="sendstatus" class="form-status"></small> </div>
</fieldset> <small id="sendstatus" class="form-status"></small>
</fieldset>
</form>
</section>
<section class="view" id="settings" hidden>
<div class="content">
<form action="#" name="profile" autocomplete="new-password">
<label for="profile_name">name</label>
<input type="text" name="name" id="profile_name" autocomplete="off" pattern="[a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9]">
<label for="profile_about">about</label>
<textarea name="about" id="profile_about"></textarea>
<label for="profile_picture">picture</label>
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" class="primary" tabindex="0" disabled>publish</button>
</div>
</form> </form>
<form action="#" name="options">
<label for="filterDifficulty">
difficulty filter<br>
<small>
hide text notes with mining proof lower
than:&nbsp;<span data-display="filter_difficulty"></span>. a zero value shows all notes.
</small>
</label>
<input type="range" name="filter_difficulty" step="1" min="0" max="32" id="filterDifficulty" value="0">
<label class="number" for="miningTarget">
<span>
mining difficulty<br>
<small>
with which difficulty to try to mine a proof of work when publishing events, such as: notes, replies, reactions and profile updates.
use zero to disable mining.
difficulty is defined as the number of leading zero bits, read more about
<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noopener noreferrer">proof of work (nip-13)</a>.
</small>
</span>
<input type="number" name="mining_target" step="1" min="0" max="32" id="miningTarget" value="16">
</label>
<label class="number" for="miningTimeout">
<span>
mining timeout<br>
<small>abort trying to find a proof if timeout (in seconds) exceeds. use 0 to mine without a time limit.</small>
</span>
<input type="number" name="mining_timeout" step="1" min="0" max="256" id="miningTimeout" value="5">
</label>
</form>
<form action="#" name="settings" autocomplete="new-password">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="btn-inline" >
<small>show</small>
</button>
</label>
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" class="primary" tabindex="0">new</button>
<button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
<p>
<a href="/about.html">about nostr.ch</a>
</p>
</footer>
</div> </div>
</artcile> </section>
<div class="cards" id="homefeed"></div> <section id="errorOverlay" class="form" hidden></section>
<div id="detail" hidden> </aside>
<article class="mbox" id="profile" data-pubkey> <!-- views are inserted here -->
<div class="mbox-body"> </main>
<img class="profile-image"> <nav>
<h2 class="profile-name mbox-username"></h2> <a href="/">home</a>
<p class="profile-about"></p> <a href="/feed">global</a>
<dl><dt class="profile-pubkey-label" hidden>pubkey</dt><dd class="profile-pubkey"></dd></dl> <span class="spacer"></span>
</div> <button tpye="button" name="settings">settings</button>
</article> </nav>
<section id="textnote"></section> </div>
</div>
</div>
<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 class="tab-content">
<p><a href="https://github.com/nostr-protocol/nips/blob/master/04.md">NIP-04 (direct msg)</a></p>
</div>
<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 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="profile" autocomplete="new-password">
<label for="profile_name">name</label>
<input type="text" name="name" id="profile_name" autocomplete="off" pattern="[a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9]">
<label for="profile_about">about</label>
<textarea name="about" id="profile_about"></textarea>
<label for="profile_picture">picture</label>
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
</div>
</form>
<form action="#" name="settings" autocomplete="new-password">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off">
<label for="privatekey">
private-key
<button type="button" name="privatekey-toggle" class="btn-inline" >
<small>show</small>
</button>
</label>
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
<p>
<a href="/about.html">about nostr.ch</a>
</p>
</footer>
</div>
</div>
</main>
</body> </body>
<script src="main.js"></script> <script src="/main.js"></script>
</html> </html>

@ -1,124 +0,0 @@
@import "tabs.css";
@import "cards.css";
@import "form.css";
@import "write.css";
:root {
/* 5px auto Highlight */
--focus-border-color: rgb(0, 122, 255);
--focus-border-radius: 2px;
--focus-outline-color: rgb(192, 227, 252);
--focus-outline-offset: 2px;
--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;
--gap: 2.4rem;
}
::selection {
background: #ff79f9;
color: #fff;
}
:where([hidden]) {
display: none !important;
}
@media (prefers-color-scheme: light) {
html {
--bgcolor: #fdfefa;
--bgcolor-accent: #7badfc;
--bgcolor-inactive: #bababa;
--bgcolor-textinput: #fff;
--color: rgb(68 68 68);
--color-accent: rgb(16, 93, 176);
--bgcolor-danger: rgb(255, 80, 80);
}
}
@media (prefers-color-scheme: dark) {
html {
--bgcolor: #191919;
--bgcolor-accent: rgb(16, 93, 176);
--bgcolor-inactive: #434343;
--bgcolor-textinput: #0e0e0e;
--color: #e3e3e3;
--color-accent: #7b7b7b;
--bgcolor-danger: rgb(169, 0, 0);
}
img {
opacity: .75;
transition: opacity .5s ease-in-out;
}
img:hover {
opacity: 1;
}
}
html {
font-size: 62.5%;
line-height: 1;
}
body {
background-color: var(--bgcolor);
color: var(--color);
font-size: 1.6rem;
line-height: 1.5;
margin: 0;
}
h1, h2, h3, h4, h5 { font-weight: normal; }
body,
button,
input,
select,
textarea {
font-family: monospace;
}
small,
time {
font-size: var(--font-small);
}
canvas,
img {
max-width: 100%;
}
.text {
margin: var(--gap);
}
.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,963 +0,0 @@
import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools';
import {elem, parseTextContent} from './domutil.js';
import {dateTime, formatTime} from './timeutil.js';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
const pool = relayPool();
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-relay.wlvs.space', {read: true, write: true});
pool.addRelay('wss://relay.nostr.ch', {read: true, write: true});
pool.addRelay('wss://nostr.sandwich.farm', {read: true, write: true});
function onEvent(evt, relay) {
switch (evt.kind) {
case 0:
handleMetadata(evt, relay);
break;
case 1:
handleTextNote(evt, relay);
break;
case 2:
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*/)
}
}
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 subList = [];
const unSubAll = () => {
subList.forEach(sub => sub.unsub());
subList.length = 0;
};
window.addEventListener('popstate', (event) => {
// console.log(`popstate path: ${location.pathname}, state: ${JSON.stringify(event.state)}`);
unSubAll();
if (event.state?.author) {
subProfile(event.state.author);
return;
}
if (event.state?.pubOrEvt) {
subNoteAndProfile(event.state.pubOrEvt);
return;
}
if (event.state?.eventId) {
subTextNote(event.state.eventId);
return;
}
sub24hFeed();
showFeed();
});
switch(location.pathname) {
case '/':
history.pushState({}, '', '/');
sub24hFeed();
break;
default:
const pubOrEvt = location.pathname.slice(1);
if (pubOrEvt.length === 64 && pubOrEvt.match(/^[0-9a-f]+$/)) {
history.pushState({pubOrEvt}, '', `/${pubOrEvt}`);
subNoteAndProfile(pubOrEvt);
}
break;
}
function sub24hFeed() {
subList.push(pool.sub({
cb: onEvent,
filter: {
kinds: [0, 1, 2, 7],
// until: Math.floor(Date.now() * 0.001),
since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)),
limit: 450,
}
}));
}
function subNoteAndProfile(id) {
subProfile(id);
subTextNote(id);
}
function subTextNote(eventId) {
subList.push(pool.sub({
cb: (evt, relay) => {
clearTextNoteDetail();
showTextNoteDetail(evt, relay);
},
filter: {
ids: [eventId],
kinds: [1],
limit: 1,
}
}));
}
function subProfile(pubkey) {
subList.push(pool.sub({
cb: (evt, relay) => {
renderProfile(evt, relay);
showProfileDetail();
},
filter: {
authors: [pubkey],
kinds: [0],
limit: 1,
}
}));
// get notes for profile
subList.push(pool.sub({
cb: (evt, relay) => {
showTextNoteDetail(evt, relay);
showProfileDetail();
},
filter: {
authors: [pubkey],
kinds: [1],
limit: 150,
}
}));
}
const detailContainer = document.querySelector('#detail');
const profileContainer = document.querySelector('#profile');
const profileAbout = profileContainer.querySelector('.profile-about');
const profileName = profileContainer.querySelector('.profile-name');
const profilePubkey = profileContainer.querySelector('.profile-pubkey');
const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label');
const profileImage = profileContainer.querySelector('.profile-image');
const textNoteContainer = document.querySelector('#textnote');
function clearProfile() {
profileAbout.textContent = '';
profileName.textContent = '';
profilePubkey.textContent = '';
profilePubkeyLabel.hidden = true;
profileImage.removeAttribute('src');
profileImage.hidden = true;
}
function renderProfile(evt, relay) {
profileContainer.dataset.pubkey = evt.pubkey;
profilePubkey.textContent = evt.pubkey;
profilePubkeyLabel.hidden = false;
const content = parseContent(evt.content);
if (content) {
profileAbout.textContent = content.about;
profileName.textContent = content.name;
const noxyImg = getNoxyUrl('data', content.picture, evt.id, relay);
if (noxyImg) {
profileImage.setAttribute('src', getNoxyUrl('data', noxyImg, evt.id, relay));
profileImage.hidden = false;
}
}
}
function showProfileDetail() {
profileContainer.hidden = false;
textNoteContainer.hidden = false;
showDetail();
}
function clearTextNoteDetail() {
textNoteContainer.replaceChildren([]);
}
function showTextNoteDetail(evt, relay) {
if (!textNoteContainer.querySelector(`[data-id="${evt.id}"]`)) {
textNoteContainer.append(createTextNote(evt, relay));
}
textNoteContainer.hidden = false;
profileContainer.hidden = true;
showDetail();
}
function showDetail() {
feedContainer.hidden = true;
detailContainer.hidden = false;
}
function showFeed() {
feedContainer.hidden = false;
detailContainer.hidden = true;
}
document.querySelector('label[for="feed"]').addEventListener('click', () => {
if (location.pathname !== '/') {
showFeed();
history.pushState({}, '', '/');
unSubAll();
sub24hFeed();
}
});
document.body.addEventListener('click', (e) => {
const button = e.target.closest('button');
const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey;
const id = e.target.closest('[data-id]')?.dataset.id;
const relay = e.target.closest('[data-relay]')?.dataset.relay;
if (button && button.name === 'reply') {
if (localStorage.getItem('reply_to') === id) {
writeInput.blur();
return;
}
appendReplyForm(button);
localStorage.setItem('reply_to', id);
return;
}
if (button && button.name === 'star') {
upvote(id, relay)
return;
}
if (button && button.name === 'back') {
hideNewMessage(true);
return;
}
const username = e.target.closest('.mbox-username')
if (username) {
history.pushState({author: pubkey}, '', `/${pubkey}`);
unSubAll();
clearProfile();
clearTextNoteDetail();
subProfile(pubkey);
showProfileDetail();
return;
}
const eventTime = e.target.closest('.mbox-header time');
if (eventTime) {
history.pushState({eventId: id, relay}, '', `/${id}`);
unSubAll();
clearTextNoteDetail();
subTextNote(id);
return;
}
// const container = e.target.closest('[data-append]');
// if (container) {
// container.append(...parseTextContent(container.dataset.append));
// delete container.dataset.append;
// return;
// }
});
const textNoteList = []; // could use indexDB
const eventRelayMap = {}; // eventId: [relay1, relay2]
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 = {};
const getReactionList = (id) => {
return reactionMap[id]?.map(({content}) => content) || [];
};
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"]');
star?.setAttribute('src', 'assets/star-fill.svg');
star?.setAttribute('title', getReactionList(eventId).join(' '));
}
}
}
// feed
const feedContainer = document.querySelector('#homefeed');
const feedDomMap = {};
const replyDomMap = {};
const restoredReplyTo = localStorage.getItem('reply_to');
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((evt, i) => {
if (feedDomMap[evt.id]) {
// TODO check eventRelayMap if event was published to different relays
return;
}
const article = createTextNote(evt, eventRelayMap[evt.id]);
if (i === 0) {
feedContainer.append(article);
} else {
feedDomMap[sortedFeeds[i - 1].id].before(article);
}
feedDomMap[evt.id] = article;
});
}
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach(timeElem => {
timeElem.textContent = formatTime(new Date(timeElem.dateTime));
});
}, 10000);
const getNoxyUrl = (type, url, id, relay) => {
if (!isHttpUrl(url)) {
return false;
}
const link = new URL(`https://noxy.nostr.ch/${type}`);
link.searchParams.set('id', id);
link.searchParams.set('relay', relay);
link.searchParams.set('url', url);
return link;
}
const fetchQue = [];
let fetchPending;
const fetchNext = (href, id, relay) => {
const noxy = getNoxyUrl('meta', href, id, relay);
const previewId = noxy.searchParams.toString();
if (fetchPending) {
fetchQue.push({href, id, relay});
return previewId;
}
fetchPending = fetch(noxy.href)
.then(data => {
if (data.status === 200) {
return data.json();
}
// fetchQue.push({href, id, relay}); // could try one more time
return Promise.reject(data);
})
.then(meta => {
const container = document.getElementById(previewId);
const content = [];
if (meta.images[0]) {
content.push(elem('img', {className: 'preview-image', loading: 'lazy', src: getNoxyUrl('data', meta.images[0], id, relay).href}));
}
if (meta.title) {
content.push(elem('h2', {className: 'preview-title'}, meta.title));
}
if (meta.descr) {
content.push(elem('p', {className: 'preview-descr'}, meta.descr))
}
if (content.length) {
container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content));
container.classList.add('preview-loaded');
}
})
.finally(() => {
fetchPending = false;
if (fetchQue.length) {
const {href, id, relay} = fetchQue.shift();
return fetchNext(href, id, relay);
}
})
.catch(err => err.text && err.text())
.then(errMsg => errMsg && console.warn(errMsg));
return previewId;
};
function linkPreview(href, id, relay) {
if ((/\.(gif|jpe?g|png)$/i).test(href)) {
return elem('div', {},
[elem('img', {className: 'preview-image-only', loading: 'lazy', src: getNoxyUrl('data', href, id, relay).href})]
);
}
const previewId = fetchNext(href, id, relay);
return elem('div', {
className: 'preview',
id: previewId
});
}
function createTextNote(evt, relay) {
const {host, img, isReply, name, replies, time, userName} = getMetadata(evt, relay);
// const isLongContent = evt.content.trimRight().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 [content, {firstLink}] = parseTextContent(evt.content);
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` : ''}
${evt.content}`
}, [
elem('small', {}, [
elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName),
' ',
elem('time', {dateTime: time.toISOString()}, formatTime(time)),
]),
]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content,
firstLink ? linkPreview(firstLink, evt.id, relay) : ''
]),
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: getReactionList(evt.id).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()) : '',
]);
if (restoredReplyTo === evt.id) {
appendReplyForm(body.querySelector('button[name="reply"]'));
requestAnimationFrame(() => updateElemHeight(writeInput));
}
return renderArticle([
elem('div', {className: 'mbox-img'}, [img]), body,
replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '',
], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
}
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.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),
' updated contacts: ',
JSON.stringify(evt.tags),
]),
]);
return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}});
}
function renderRecommendServer(evt, relay) {
const {img, name, time, userName} = getMetadata(evt, relay);
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [
elem('small', {}, [
elem('strong', {}, userName)
]),
]),
` recommends server: ${evt.content}`,
]);
return renderArticle([
elem('div', {className: 'mbox-img'}, [img]), body
], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}});
}
function renderArticle(content, props = {}) {
const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox';
return elem('article', {...props, className}, content);
}
const userList = [];
// const tempContactList = {};
function parseContent(content) {
try {
return JSON.parse(content);
} catch(err) {
console.log(evt);
console.error(err);
}
}
function handleMetadata(evt, relay) {
const content = parseContent(evt.content);
if (content) {
setMetadata(evt, relay, content);
}
}
function setMetadata(evt, relay, content) {
let user = userList.find(u => u.pubkey === evt.pubkey);
const picture = getNoxyUrl('data', content.picture, evt.id, relay).href;
if (!user) {
user = {
metadata: {[relay]: content},
...(content.picture && {picture}),
pubkey: evt.pubkey,
};
userList.push(user);
} else {
user.metadata[relay] = {
...user.metadata[relay],
timestamp: evt.created_at,
...content,
};
// use only the first profile pic (for now), different pics on each releay are not supported yet
if (!user.picture) {
user.picture = picture;
}
}
// update profile images
if (user.picture) {
document.body
.querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`)
.forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas)));
}
if (user.metadata[relay].name) {
document.body
.querySelectorAll(`[data-id="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`)
.forEach(username => {
username.textContent = user.metadata[relay].name;
username.classList.add('mbox-kind0-name');
});
}
// if (tempContactList[relay]) {
// const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey);
// if (updates) {
// console.log('TODO: add contact list (kind 3)', updates);
// }
// }
}
function isHttpUrl(string) {
try {
return ['http:', 'https:'].includes(new URL(string).protocol);
} catch (err) {
return false;
}
}
const getHost = (url) => {
try {
return new URL(url).host;
} catch(err) {
return err;
}
}
const elemCanvas = (text) => {
const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}});
const context = canvas.getContext('2d');
const color = `#${text.slice(0, 6)}`;
context.fillStyle = color;
context.fillRect(0, 0, 80, 80);
context.fillStyle = '#111';
context.fillRect(0, 50, 80, 32);
context.font = 'bold 18px monospace';
if (color === '#000000') {
context.fillStyle = '#fff';
}
context.fillText(text.slice(0, 8), 2, 46);
return canvas;
}
function getMetadata(evt, relay) {
const host = getHost(relay);
const user = userList.find(user => user.pubkey === evt.pubkey);
const userImg = user?.picture;
const name = user?.metadata[relay]?.name;
const userName = name || evt.pubkey.slice(0, 8);
const userAbout = user?.metadata[relay]?.about || '';
const img = userImg ? elem('img', {
alt: `${userName} ${host}`,
loading: 'lazy',
src: userImg,
title: `${userName} on ${host} ${userAbout}`,
}) : elemCanvas(evt.pubkey);
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, name, replies, time, userName};
}
const writeForm = document.querySelector('#writeForm');
const writeInput = document.querySelector('textarea[name="message"]');
const elemShrink = () => {
const height = writeInput.style.height || writeInput.getBoundingClientRect().height;
const shrink = elem('div', {className: 'shrink-out'});
shrink.style.height = `${height}px`;
shrink.addEventListener('animationend', () => shrink.remove(), {once: true});
return shrink;
}
writeInput.addEventListener('focusout', () => {
const reply_to = localStorage.getItem('reply_to');
if (reply_to && writeInput.value === '') {
writeInput.addEventListener('transitionend', (event) => {
if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height
writeForm.after(elemShrink());
writeForm.remove();
localStorage.removeItem('reply_to');
}
}, {once: true});
}
});
function appendReplyForm(el) {
writeForm.before(elemShrink());
writeInput.blur();
writeInput.style.removeProperty('height');
el.after(writeForm);
if (writeInput.value && !writeInput.value.trimRight()) {
writeInput.value = '';
} else {
requestAnimationFrame(() => updateElemHeight(writeInput));
}
requestAnimationFrame(() => writeInput.focus());
}
const newMessageDiv = document.querySelector('#newMessage');
document.querySelector('#bubble').addEventListener('click', (e) => {
localStorage.removeItem('reply_to'); // should it forget old replyto context?
newMessageDiv.prepend(writeForm);
hideNewMessage(false);
writeInput.focus();
if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height');
}
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => updateElemHeight(writeInput));
});
document.body.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
hideNewMessage(true);
}
});
function hideNewMessage(hide) {
document.body.style.removeProperty('overflow');
newMessageDiv.hidden = hide;
}
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;
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'));
}
const content = writeInput.value.trimRight();
if (!content) {
return onSendError(new Error('message is empty'));
}
const replyTo = localStorage.getItem('reply_to');
const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : [];
const newEvent = {
kind: 1,
pubkey,
content,
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.textContent = '';
writeInput.value = '';
writeInput.style.removeProperty('height');
publish.disabled = true;
if (replyTo) {
localStorage.removeItem('reply_to');
newMessageDiv.append(writeForm);
}
hideNewMessage(true);
// console.info(`event published by ${url}`, ev);
}
});
}
});
writeInput.addEventListener('input', () => {
publish.disabled = !writeInput.value.trimRight();
updateElemHeight(writeInput);
});
writeInput.addEventListener('blur', () => sendStatus.textContent = '');
function updateElemHeight(el) {
el.style.removeProperty('height');
if (el.value) {
el.style.paddingBottom = 0;
el.style.paddingTop = 0;
el.style.height = el.scrollHeight + 'px';
el.style.removeProperty('padding-bottom');
el.style.removeProperty('padding-top');
}
}
// settings
const settingsForm = document.querySelector('form[name="settings"]');
const privateKeyInput = settingsForm.querySelector('#privatekey');
const pubKeyInput = settingsForm.querySelector('#pubkey');
const statusMessage = settingsForm.querySelector('#keystatus');
const generateBtn = settingsForm.querySelector('button[name="generate"]');
const importBtn = settingsForm.querySelector('button[name="import"]');
const privateTgl = settingsForm.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;
statusMessage.textContent = 'private-key created!';
statusMessage.hidden = false;
}
});
importBtn.addEventListener('click', () => {
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;
}
});
settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value));
privateKeyInput.addEventListener('paste', (event) => {
if (pubKeyInput.value || !event.clipboardData) {
return;
}
if (privateKeyInput.value === '' || ( // either privatekey field is empty
privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard
&& privateKeyInput.selectionEnd === privateKeyInput.value.length
)) { // only generate the pubkey if no data other than the text from clipboard will be used
try {
pubKeyInput.value = getPublicKey(event.clipboardData.getData('text'));
} catch(err) {} // settings form will call validKeys on input and display the error
}
});
function validKeys(privatekey, pubkey) {
try {
if (getPublicKey(privatekey) === pubkey) {
statusMessage.hidden = true;
statusMessage.textContent = 'public-key corresponds to private-key';
importBtn.removeAttribute('disabled');
return true;
} else {
statusMessage.textContent = 'private-key does not correspond to public-key!'
}
} catch (e) {
statusMessage.textContent = `not a valid private-key: ${e.message || e}`;
}
statusMessage.hidden = false;
importBtn.setAttribute('disabled', true);
return false;
}
privateTgl.addEventListener('click', () => {
privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text';
});
privateKeyInput.value = localStorage.getItem('private_key');
pubKeyInput.value = localStorage.getItem('pub_key');
// profile
const profileForm = document.querySelector('form[name="profile"]');
const profileSubmit = profileForm.querySelector('button[type="submit"]');
const profileStatus = document.querySelector('#profilestatus');
const onProfileError = err => {
profileStatus.hidden = false;
profileStatus.textContent = err.message
};
profileForm.addEventListener('input', (e) => {
if (e.target.nodeName === 'TEXTAREA') {
updateElemHeight(e.target);
}
const form = new FormData(profileForm);
const name = form.get('name');
const about = form.get('about');
const picture = form.get('picture');
profileSubmit.disabled = !(name || about || picture);
});
profileForm.addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(profileForm);
const privatekey = localStorage.getItem('private_key');
const newProfile = {
kind: 0,
pubkey,
content: JSON.stringify(Object.fromEntries(form)),
created_at: Math.floor(Date.now() * 0.001),
tags: [],
};
const sig = await signEvent(newProfile, privatekey).catch(console.error);
if (sig) {
const ev = await pool.publish({...newProfile, sig}, (status, url) => {
if (status === 0) {
console.info(`publish request sent to ${url}`);
}
if (status === 1) {
profileStatus.textContent = 'profile metadata successfully published';
profileStatus.hidden = false;
profileSubmit.disabled = true;
}
}).catch(console.error);
}
});

@ -0,0 +1,443 @@
import {Event, nip19} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto';
import {elem} from './utils/dom';
import {bounce} from './utils/time';
import {isWssUrl} from './utils/url';
import {closeSettingsView, config, toggleSettingsView} from './settings';
import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts, subContactList} from './subscriptions'
import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write';
import {handleMetadata, renderProfile} from './profiles';
import {followContact, getContactUpdateMessage, getContacts, getOwnContacts, refreshFollowing, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
type EventRelayMap = {
[eventId: string]: string[];
};
const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2]
const renderNote = (
evt: EventWithNip19,
i: number,
sortedFeeds: EventWithNip19[],
) => {
if (getViewElem(evt.id)) { // note already in view
return;
}
const article = createTextNote(evt, eventRelayMap[evt.id][0]);
if (i === 0) {
getViewContent().append(article);
} else {
getViewElem(sortedFeeds[i - 1].id).before(article);
}
setViewElem(evt.id, article);
};
const renderContact = (pubkey: string) => {
if (getViewElem(`contact-${pubkey}`)) { // contact already in view
updateFollowBtn(pubkey);
return;
}
const contact = createContact(pubkey);
if (contact) {
getViewContent().append(contact);
setViewElem(`contact-${pubkey}`, contact);
}
};
const hasEnoughPOW = (
[tag, , commitment]: string[],
eventId: string
) => {
return tag === 'nonce' && Number(commitment) >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty;
};
const renderFeed = bounce(() => {
const view = getViewOptions();
switch (view.type) {
case 'note':
textNoteList
.concat(replyList) // search id in notes and replies
.filter(note => note.id === view.id)
.forEach(renderNote);
break;
case 'profile':
[
...textNoteList // get notes
.filter(note => note.pubkey === view.id),
...replyList.filter(reply => reply.pubkey === view.id) // and replies
.map(reply => textNoteList.find(note => note.id === reply.replyTo)) // and the replied to notes
.filter(isEvent)
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
renderProfile(view.id);
refreshFollowing(view.id);
break;
case 'home':
const ids = view.id ? getContacts(view.id) : getOwnContacts();
[
...textNoteList
.filter(note => ids.includes(note.pubkey)),
...replyList // search id in notes and replies
.filter(reply => ids.includes(reply.pubkey))
.map(reply => textNoteList.find(note => note.id === reply.replyTo))
.filter(isEvent),
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
break;
case 'feed':
const now = Math.floor(Date.now() * 0.001);
textNoteList
.filter(note => {
// dont render notes from the future
if (note.created_at > now) return false;
// if difficulty filter is configured dont render notes with too little pow
return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))
})
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
break;
case 'contacts':
getContacts(view.id)
.forEach(renderContact);
break;
}
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
const renderReply = (evt: EventWithNip19AndReplyTo) => {
const parent = getViewElem(evt.replyTo);
if (!parent || getViewElem(evt.id)) {
return;
}
let replyContainer = parent.querySelector('.mbox-replies');
if (!replyContainer) {
replyContainer = elem('div', {className: 'mbox-replies'});
parent.append(replyContainer);
parent.classList.add('mbox-has-replies');
}
const reply = createTextNote(evt, eventRelayMap[evt.id][0]);
replyContainer.append(reply);
setViewElem(evt.id, reply);
};
const handleReply = (evt: EventWithNip19, relay: string) => {
if (
getViewElem(evt.id) // already rendered probably received from another relay
|| evt.tags.some(isMention) // ignore mentions for now
) {
return;
}
const replyTo = getReplyTo(evt);
if (!replyTo) {
return;
}
const evtWithReplyTo = {replyTo, ...evt};
replyList.push(evtWithReplyTo);
renderReply(evtWithReplyTo);
};
const handleTextNote = (evt: Event, relay: string) => {
if (evt.content.startsWith('vmess://') && !evt.content.includes(' ')) {
console.info('drop VMESS encrypted message');
return;
}
if (eventRelayMap[evt.id]) {
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem?
} else {
eventRelayMap[evt.id] = [relay];
const evtWithNip19 = {
nip19: {
note: nip19.noteEncode(evt.id),
npub: nip19.npubEncode(evt.pubkey),
},
...evt,
};
if (evt.tags.some(hasEventTag)) {
handleReply(evtWithNip19, relay);
} else {
textNoteList.push(evtWithNip19);
}
}
if (!getViewElem(evt.id)) {
renderFeed();
}
};
const rerenderFeed = () => {
clearView();
renderFeed();
};
config.rerenderFeed = rerenderFeed;
const handleContactList = (evt: Event, relay: string) => {
// TODO: if newer and view.type === 'home' rerenderFeed()
setContactList(evt);
const view = getViewOptions();
if (getViewElem(evt.id)) {
return;
}
if (
view.type === 'contacts'
&& [view.id, config.pubkey].includes(evt.pubkey) // render if contact-list is from current users or current view
) {
renderFeed();
return;
}
if (view.type === 'profile' && view.id === evt.pubkey) {
// use find instead of sort?
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
const closestNote = getViewElem(closestTextNotes[0].id);
if (!closestNote) {
// no close note, try later
setTimeout(() => handleContactList(evt, relay), 1500);
return;
};
const [addedContacts, removedContacts] = updateContactList(evt);
const content = getContactUpdateMessage(addedContacts, removedContacts);
if (!content.length) {
// P same as before, maybe only evt.content or 'a' tags changed?
return;
}
const art = renderUpdateContact({...evt, content}, relay);
closestNote.after(art);
setViewElem(evt.id, art);
}
};
const handleRecommendServer = (evt: Event, relay: string) => {
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return;
}
const art = renderRecommendServer(evt, relay);
if (textNoteList.length < 2) {
getViewContent().append(art);
} else {
const closestTextNotes = textNoteList
// TODO: prob change to hasEnoughPOW
.filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty))
// use find instead of sort?
.sort(sortEventCreatedAt(evt.created_at));
getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
}
setViewElem(evt.id, art);
};
const onEventDetails = (evt: Event, relay: string) => {
if (getViewElem(evt.id)) {
return;
}
const article = renderEventDetails(evt, relay);
getViewContent().append(article);
setViewElem(evt.id, article);
};
const onEvent = (evt: Event, relay: string) => {
switch (evt.kind) {
case 0:
handleMetadata(evt, relay);
break;
case 1:
handleTextNote(evt, relay);
break;
case 2:
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*/)
}
};
// subscribe and change view
const route = (path: string) => {
if (path === '/') {
const contactList = getOwnContacts();
if (contactList.length) {
subPubkeys(contactList, onEvent);
view('/', {type: 'home'});
} else {
subGlobalFeed(onEvent);
view('/feed', {type: 'feed'});
}
return;
}
if (path === '/feed') {
subGlobalFeed(onEvent);
view('/feed', {type: 'feed'});
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') {
console.warn('nip19 ProfilePointer, EventPointer and AddressPointer are not yet supported');
return;
}
switch(type) {
case 'note':
subNote(data, onEvent);
view(path, {type: 'note', id: data});
break;
case 'npub':
subProfile(data, onEvent);
view(path, {type: 'profile', id: data});
updateFollowBtn(data);
break;
default:
console.warn(`type ${type} not yet supported`);
}
renderFeed();
} else if (path.length === 73 && path.match(/^\/contacts\/npub[0-9a-z]+$/)) {
const contactNpub = path.slice(10);
const {type: contactType, data: contactPubkey} = nip19.decode(contactNpub);
if (contactType === 'npub') {
subContactList(contactPubkey, onEvent);
view(path, {type: 'contacts', id: contactPubkey});
}
} else if (path.length === 73 && path.match(/^\/timeline\/npub[0-9a-z]+$/)) {
const timelineNpub = path.slice(10);
const {type: timelineType, data: timelinePubkey} = nip19.decode(timelineNpub);
if (timelineType === 'npub') {
const timelinePubkeys = getContacts(timelinePubkey);
subPubkeys(timelinePubkeys, onEvent);
view(path, {type: 'home', id: timelinePubkey});
}
} else if (path.length === 65) {
const eventID = path.slice(1);
subEventID(eventID, onEventDetails);
view(path, {type: 'event', id: eventID});
} else {
console.warn('no support for ', path);
}
};
// onload
route(location.pathname);
subOwnContacts(onEvent); // subscribe after route as routing unsubscribes current subs
// only push a new entry if there is no history onload
if (!history.length) {
history.pushState({}, '', location.pathname);
}
window.addEventListener('popstate', (event) => {
route(location.pathname);
});
const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
const href = a.getAttribute('href');
if (typeof href !== 'string') {
console.warn('expected anchor to have href attribute', a);
return;
}
closeSettingsView();
closePublishView();
if (href === location.pathname) {
e.preventDefault();
return;
}
if (
href === '/'
|| href.startsWith('/feed')
|| href.startsWith('/note')
|| href.startsWith('/npub')
|| href.startsWith('/contacts/npub')
|| href.startsWith('/timeline/npub')
|| (href.startsWith('/') && href.length === 65)
) {
route(href);
history.pushState({}, '', href);
e.preventDefault();
}
};
const handleButton = (button: HTMLButtonElement) => {
switch(button.name) {
case 'settings':
toggleSettingsView();
return;
case 'new-note':
togglePublishView();
return;
case 'back':
closePublishView();
return;
case 'import':
resetContactList(config.pubkey);
rerenderFeed();
subOwnContacts(onEvent);
subGlobalFeed(onEvent);
return;
}
const id = button.dataset.id || (button.closest('[data-id]') as HTMLElement)?.dataset.id;
if (id) {
switch(button.name) {
case 'reply':
openWriteInput(button, id);
return;
case 'star':
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note);
return;
case 'follow':
followContact(id);
return;
}
}
// const container = e.target.closest('[data-append]');
// if (container) {
// container.append(...parseTextContent(container.dataset.append));
// delete container.dataset.append;
// return;
// }
};
const handleContentClick = (content: HTMLElement) => {
const card = content.closest('article[data-id]') as HTMLElement;
if (
!card
|| !card.dataset.id
|| !card.dataset.kind
|| getSelection()?.toString() // do not navigate if user selects text
) {
return;
}
const {kind, id} = card.dataset;
const href = `/${kind === '1' ? nip19.noteEncode(id) : id}`;
route(href);
history.pushState({}, '', href);
};
document.body.addEventListener('click', (event: MouseEvent) => {
// dont intercept command or shift-click
if (event.metaKey || event.shiftKey) {
return;
}
const target = event.target as HTMLElement;
const a = target?.closest('a');
if (a) {
handleLink(a, event);
return;
}
const button = target?.closest('button');
if (button) {
handleButton(button);
return;
}
const card = target?.closest('.mbox-body');
if (card) {
handleContentClick(card as HTMLElement);
}
});

@ -0,0 +1,114 @@
import { elem } from './utils/dom';
import { getNoxyUrl } from './utils/url';
export const parseJSON = (content: string): unknown => {
try {
return JSON.parse(content);
} catch(err) {
console.warn(err, content);
return null;
}
}
type FetchItem = {
href: string;
id: string;
relay: string;
};
type NoxyData = {
title: string;
descr: string;
images: string[];
};
const fetchQue: Array<FetchItem> = [];
let fetchPending: (null | Promise<NoxyData>) = null;
const fetchNext = (
href: string,
id: string,
relay: string,
) => {
const noxy = getNoxyUrl('meta', href, id, relay);
if (!noxy) {
return false;
}
const previewId = noxy.searchParams.toString();
if (fetchPending) {
fetchQue.push({href, id, relay});
return previewId;
}
fetchPending = fetch(noxy.href)
.then(data => {
if (data.status === 200) {
return data.json();
}
// fetchQue.push({href, id, relay}); // could try one more time
return Promise.reject(data);
})
.then(meta => {
const container = document.getElementById(previewId);
const content: Array<HTMLElement> = [];
if (meta.images[0]) {
const img = getNoxyUrl('data', meta.images[0], id, relay);
img && content.push(
elem('img', {
className: 'preview-image',
loading: 'lazy',
src: img.href,
})
);
}
if (meta.title) {
content.push(elem('h2', {className: 'preview-title'}, meta.title));
}
if (meta.descr) {
content.push(elem('p', {className: 'preview-descr'}, meta.descr))
}
if (container && content.length) {
container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content));
container.classList.add('preview-loaded');
}
})
.finally(() => {
fetchPending = null;
if (fetchQue.length) {
const {href, id, relay} = fetchQue.shift() as FetchItem;
return fetchNext(href, id, relay);
}
})
.catch(err => err.text && err.text())
.then(errMsg => errMsg && console.warn(errMsg));
return previewId;
};
export const linkPreview = (
href: string,
id: string,
relay: string,
) => {
if ((/\.(gif|jpe?g|png)$/i).test(href)) {
const img = getNoxyUrl('data', href, id, relay);
if (!img) {
return null;
}
return elem('div', {},
[elem('img', {
className: 'preview-image-only',
loading: 'lazy',
src: img.href,
})]
);
}
const previewId = fetchNext(href, id, relay);
if (!previewId) {
return null;
}
return elem('div', {
className: 'preview',
id: previewId,
});
};

@ -0,0 +1,16 @@
import {Event} from 'nostr-tools';
export type EventWithNip19 = Event & {
nip19: {
note: string;
npub: string;
}
};
export const textNoteList: Array<EventWithNip19> = []; // could use indexDB
export type EventWithNip19AndReplyTo = EventWithNip19 & {
replyTo: string;
};
export const replyList: Array<EventWithNip19AndReplyTo> = [];

@ -0,0 +1,149 @@
import {Event} from 'nostr-tools';
import {elem, elemCanvas, parseTextContent} from './utils/dom';
import {getNoxyUrl} from './utils/url';
import {getViewElem, getViewOptions} from './view';
import {parseJSON} from './media';
import {sortByCreatedAt} from './events';
type Profile = {
name: string;
about?: string;
picture?: string;
website?: string;
}
const transformMetadata = (data: unknown): Profile | undefined => {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
console.warn('expected nip-01 JSON object with user info, but got something funny', data);
return;
}
const hasNameString = 'name' in data && typeof data.name === 'string';
const hasAboutString = 'about' in data && typeof data.about === 'string';
const hasPictureString = 'picture' in data && typeof data.picture === 'string';
// custom
const hasDisplayName = 'display_name' in data && typeof data.display_name === 'string';
const hasWebsite = 'website' in data && typeof data.website === 'string';
if (!hasNameString && !hasAboutString && !hasPictureString && !hasDisplayName) {
console.warn('expected basic nip-01 user info (name, about, picture) but nothing found', data);
return;
}
const name = (
hasNameString && data.name as string
|| hasDisplayName && data.display_name as string
|| ''
);
return {
name,
...(hasAboutString && {about: data.about as string}),
...(hasPictureString && {picture: data.picture as string}),
...(hasWebsite && {website: data.website as string})
};
};
const profileMap: {
[pubkey: string]: Profile
} = {};
const metadataList: Array<Event> = [];
export const handleMetadata = (evt: Event, relay: string) => {
if (metadataList.find(({id}) => id === evt.id)) {
return;
}
const contactEventList = metadataList.filter(({pubkey}) => pubkey === evt.pubkey);
metadataList.push(evt);
contactEventList.push(evt);
contactEventList.sort(sortByCreatedAt);
if (contactEventList.some(({created_at}) => created_at > evt.created_at) ) {
// evt is older
return;
}
const content = parseJSON(evt.content);
const metadata = transformMetadata(content);
if (!metadata) {
return;
}
profileMap[evt.pubkey] = metadata;
if (metadata.picture) {
const imgUrl = getNoxyUrl('data', metadata.picture, evt.id, relay);
if (imgUrl) {
// update profile images that used some nip-13 work
// if (imgUrl.href && validatePow(evt)) {
// document.body
// .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`)
// .forEach(canvas => canvas.parentNode?.replaceChild(elem('img', {src: imgUrl.href}), canvas));
// }
}
}
if (metadata.name) {
// update profile names
document.body
.querySelectorAll(`[data-profile="${evt.pubkey}"]`)
.forEach((username: HTMLElement) => {
username.textContent = metadata.name;
username.classList.add('mbox-kind0-name');
});
}
if (metadata.about) {
const about = getViewElem(`about-${evt.pubkey}`);
if (about) {
const view = getViewOptions();
about.replaceChildren(...parseTextContent(
view.type === 'contacts'
? metadata.about.split('\n')[0]
: metadata.about
)[0]);
}
}
};
export const getMetadata = (pubkey: string) => {
const user = profileMap[pubkey];
const about = user?.about;
const name = user?.name;
const userName = name || pubkey.slice(0, 8);
// const userImg = user?.picture;
const img = /* (userImg && validatePow(evt)) ? elem('img', {
alt: `${userName} ${host}`,
loading: 'lazy',
src: userImg,
title: `${userName} on ${host} ${userAbout}`,
}) : */ elemCanvas(pubkey);
return {about, img, name, userName};
};
export const renderProfile = (pubkey: string) => {
const header = getViewElem('header');
const metadata = profileMap[pubkey];
if (!header || !metadata) {
return;
}
if (metadata.name) {
const h1 = header.querySelector('h1');
if (h1) {
h1.textContent = metadata.name;
document.title = metadata.name;
} else {
header.append(elem('h1', {}, metadata.name));
}
}
const detail = getViewElem(`detail-${pubkey}`);
if (metadata.about && !detail.children.length) {
const [content] = parseTextContent(metadata.about);
detail?.append(...content);
}
if (metadata.website) {
const website = detail.querySelector('[data-website]');
if (website) {
const url = metadata.website.toLowerCase().startsWith('http') ? metadata.website : `https://${metadata.website}`;
const [content] = parseTextContent(url);
website.replaceChildren(...content);
(website as HTMLDivElement).hidden = false;
} else {
detail.append(elem('div', {data: {website: ''}}, metadata.name));
}
}
};

@ -0,0 +1,105 @@
import {Event, signEvent, UnsignedEvent} from 'nostr-tools';
import {powEvent} from './system';
import {publish} from './relays';
import {hasEventTag} from './events';
import {getViewElem} from './view';
import {config} from './settings';
type ReactionMap = {
[eventId: string]: Array<Event>
};
const reactionMap: ReactionMap = {};
export const getReactions = (eventId: string) => reactionMap[eventId] || [];
export const getReactionContents = (eventId: string) => {
return reactionMap[eventId]?.map(({content}) => content) || [];
};
export const handleReaction = (
evt: Event,
relay: string,
) => {
// last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md
const lastEventTag = evt.tags.filter(hasEventTag).at(-1);
if (!lastEventTag || !evt.content.length) {
// ignore reactions with no content
return;
}
const [, eventId] = lastEventTag;
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 = getViewElem(eventId);
if (article) {
const button = article.querySelector('button[name="star"]') as HTMLButtonElement;
const reactions = button.querySelector('[data-reactions]') as HTMLElement;
reactions.textContent = `${reactionMap[eventId].length || ''}`;
if (evt.pubkey === config.pubkey) {
const star = button.querySelector('img[src*="star"]');
star?.setAttribute('src', '/assets/star-fill.svg');
star?.setAttribute('title', getReactionContents(eventId).join(' '));
}
}
};
const upvote = async (
eventId: string,
evt: UnsignedEvent,
) => {
const article = getViewElem(eventId);
const reactionBtn = article.querySelector('button[name="star"]') as HTMLButtonElement;
const statusElem = article.querySelector('[data-reactions]') as HTMLElement;
reactionBtn.disabled = true;
const newReaction = await powEvent(evt, {
difficulty: config.difficulty,
statusElem,
timeout: config.timeout,
}).catch(console.warn);
if (!newReaction) {
statusElem.textContent = `${getReactions(eventId)?.length}`;
reactionBtn.disabled = false;
return;
}
const privatekey = localStorage.getItem('private_key');
if (!privatekey) {
statusElem.textContent = 'no private key to sign';
statusElem.hidden = false;
return;
}
const sig = signEvent(newReaction, privatekey);
// TODO: validateEvent
if (sig) {
statusElem.textContent = 'publishing…';
publish({...newReaction, sig}, (relay, error) => {
if (error) {
return console.error(error, relay);
}
console.info(`event published by ${relay}`);
});
reactionBtn.disabled = false;
}
};
export const handleUpvote = (evt: Event) => {
const tags = [
...evt.tags
.filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event
.map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields, TODO: use relay?
['e', evt.id], ['p', evt.pubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25)
];
upvote(evt.id, {
kind: 7,
pubkey: config.pubkey,
content: '+',
tags,
created_at: Math.floor(Date.now() * 0.001),
});
};

@ -0,0 +1,111 @@
import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools';
type SubCallback = (
event: Readonly<Event>,
relay: Readonly<string>,
) => void;
type Subscribe = {
cb: SubCallback;
filter: Filter;
unsub?: boolean;
};
const subList: Array<Sub> = [];
const currentSubList: Array<Subscribe> = [];
const relayMap = new Map<string, Relay>();
export const addRelay = async (url: string) => {
const relay = relayInit(url);
relay.on('connect', () => {
console.info(`connected to ${relay.url}`);
});
relay.on('error', () => {
console.warn(`failed to connect to ${relay.url}`);
});
try {
await relay.connect();
currentSubList.forEach(({cb, filter}) => subscribe(cb, filter, relay));
relayMap.set(url, relay);
} catch {
console.warn(`could not connect to ${url}`);
}
};
export const unsubscribe = (sub: Sub) => {
sub.unsub();
subList.splice(subList.indexOf(sub), 1);
};
const subscribe = (
cb: SubCallback,
filter: Filter,
relay: Relay,
unsub?: boolean
) => {
const sub = relay.sub([filter]);
subList.push(sub);
sub.on('event', (event: Event) => {
cb(event, relay.url);
});
if (unsub) {
sub.on('eose', () => {
// console.log('eose', relay.url);
unsubscribe(sub);
});
}
return sub;
};
export const sub = (obj: Subscribe) => {
currentSubList.push(obj);
relayMap.forEach((relay) => subscribe(obj.cb, obj.filter, relay, obj.unsub));
};
export const subOnce = (
obj: Subscribe & {relay: string}
) => {
const relay = relayMap.get(obj.relay);
if (relay) {
const sub = subscribe(obj.cb, obj.filter, relay);
sub.on('eose', () => {
// console.log('eose', obj.relay);
unsubscribe(sub);
});
}
};
export const unsubAll = () => {
subList.forEach(unsubscribe);
currentSubList.length = 0;
};
type PublishCallback = (
relay: string,
errorMessage?: string,
) => void;
export const publish = (
event: Event,
cb: PublishCallback,
) => {
relayMap.forEach((relay, url) => {
const pub = relay.publish(event);
pub.on('ok', () => {
console.info(`${relay.url} has accepted our event`);
cb(relay.url);
});
pub.on('failed', (reason: any) => {
console.error(`failed to publish to ${relay.url}: ${reason}`);
cb(relay.url, reason);
});
});
};
addRelay('wss://relay.snort.social');
addRelay('wss://nostr.bitcoiner.social');
addRelay('wss://nostr.mom');
addRelay('wss://relay.nostr.bg');
addRelay('wss://nos.lol');
// addRelay('wss://relay.nostr.ch');

@ -0,0 +1,236 @@
import {generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools';
import {updateElemHeight} from './utils/dom';
import {powEvent} from './system';
import {publish} from './relays';
const settingsView = document.querySelector('#settings') as HTMLElement;
export const closeSettingsView = () => settingsView.hidden = true;
export const toggleSettingsView = () => settingsView.hidden = !settingsView.hidden;
let pubkey: string = '';
const loadOrGenerateKeys = () => {
const storedPubKey = localStorage.getItem('pub_key');
if (storedPubKey) {
return storedPubKey;
}
const privatekey = generatePrivateKey();
const pubkey = getPublicKey(privatekey);
localStorage.setItem('private_key', privatekey);
localStorage.setItem('pub_key', pubkey);
return pubkey;
};
let filterDifficulty: number = 0;
let difficulty: number = 16;
let timeout: number = 5;
let rerenderFeed: (() => void) | undefined;
/**
* global config object
* config.pubkey, if not set loaded from localStorage or generate a new key
*/
export const config = {
get pubkey() {
if (!pubkey) {
pubkey = loadOrGenerateKeys();
}
return pubkey;
},
set pubkey(value) {
console.info(`pubkey was set to ${value}`);
pubkey = value;
},
get filterDifficulty() {
return filterDifficulty;
},
get difficulty() {
return difficulty;
},
get timeout() {
return timeout;
},
set rerenderFeed(value: () => void) {
rerenderFeed = value;
}
};
const getNumberFromStorage = (
item: string,
fallback: number,
) => {
const stored = localStorage.getItem(item);
if (!stored) {
return fallback;
}
return Number(stored);
};
// filter difficulty
const filterDifficultyInput = document.querySelector('#filterDifficulty') as HTMLInputElement;
const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]') as HTMLElement;
filterDifficultyInput.addEventListener('input', (e) => {
localStorage.setItem('filter_difficulty', filterDifficultyInput.value);
filterDifficulty = filterDifficultyInput.valueAsNumber;
filterDifficultyDisplay.textContent = filterDifficultyInput.value;
rerenderFeed && rerenderFeed();
});
filterDifficulty = getNumberFromStorage('filter_difficulty', 0);
filterDifficultyInput.valueAsNumber = filterDifficulty;
filterDifficultyDisplay.textContent = filterDifficultyInput.value;
// mining difficulty target
const miningTargetInput = document.querySelector('#miningTarget') as HTMLInputElement;
miningTargetInput.addEventListener('input', (e) => {
localStorage.setItem('mining_target', miningTargetInput.value);
difficulty = miningTargetInput.valueAsNumber;
});
// arbitrary difficulty default, still experimenting.
difficulty = getNumberFromStorage('mining_target', 16);
miningTargetInput.valueAsNumber = difficulty;
// mining timeout
const miningTimeoutInput = document.querySelector('#miningTimeout') as HTMLInputElement;
miningTimeoutInput.addEventListener('input', (e) => {
localStorage.setItem('mining_timeout', miningTimeoutInput.value);
timeout = miningTimeoutInput.valueAsNumber;
});
timeout = getNumberFromStorage('mining_timeout', 5);
miningTimeoutInput.valueAsNumber = timeout;
// settings
const settingsForm = document.querySelector('form[name="settings"]') as HTMLFormElement;
const privateKeyInput = settingsForm.querySelector('#privatekey') as HTMLInputElement;
const pubKeyInput = settingsForm.querySelector('#pubkey') as HTMLInputElement;
const statusMessage = settingsForm.querySelector('#keystatus') as HTMLElement;
const generateBtn = settingsForm.querySelector('button[name="generate"]') as HTMLButtonElement;
const importBtn = settingsForm.querySelector('button[name="import"]') as HTMLButtonElement;
const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]') as HTMLButtonElement;
const validKeys = (
privatekey: string,
pubkey: string,
) => {
try {
if (getPublicKey(privatekey) === pubkey) {
statusMessage.hidden = true;
statusMessage.textContent = 'public-key corresponds to private-key';
importBtn.removeAttribute('disabled');
return true;
} else {
statusMessage.textContent = 'private-key does not correspond to public-key!'
}
} catch (e) {
statusMessage.textContent = `not a valid private-key: ${e.message || e}`;
}
statusMessage.hidden = false;
importBtn.disabled = true;
return false;
};
generateBtn.addEventListener('click', () => {
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 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;
config.pubkey = pubkeyInput;
}
});
settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value));
privateKeyInput.addEventListener('paste', (event) => {
if (pubKeyInput.value || !event.clipboardData) {
return;
}
if (privateKeyInput.value === '' || ( // either privatekey field is empty
privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard
&& privateKeyInput.selectionEnd === privateKeyInput.value.length
)) { // only generate the pubkey if no data other than the text from clipboard will be used
try {
pubKeyInput.value = getPublicKey(event.clipboardData.getData('text'));
} catch(err) {} // settings form will call validKeys on input and display the error
}
});
privateTgl.addEventListener('click', () => {
privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text';
});
privateKeyInput.value = localStorage.getItem('private_key') || '';
pubKeyInput.value = localStorage.getItem('pub_key') || '';
// profile
const profileForm = document.querySelector('form[name="profile"]') as HTMLFormElement;
const profileSubmit = profileForm.querySelector('button[type="submit"]') as HTMLButtonElement;
const profileStatus = document.querySelector('#profilestatus') as HTMLElement;
profileForm.addEventListener('input', (e) => {
if (e.target instanceof HTMLElement) {
if (e.target?.nodeName === 'TEXTAREA') {
updateElemHeight(e.target as HTMLTextAreaElement);
}
}
const form = new FormData(profileForm);
const name = form.get('name');
const about = form.get('about');
const picture = form.get('picture');
profileSubmit.disabled = !(name || about || picture);
});
profileForm.addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(profileForm);
const newProfile = await powEvent({
kind: 0,
pubkey: config.pubkey,
content: JSON.stringify(Object.fromEntries(form)),
tags: [],
created_at: Math.floor(Date.now() * 0.001)
}, {
difficulty: config.difficulty,
statusElem: profileStatus,
timeout: config.timeout,
}).catch(console.warn);
if (!newProfile) {
profileStatus.textContent = 'publishing profile data canceled';
profileStatus.hidden = false;
return;
}
const privatekey = localStorage.getItem('private_key');
if (!privatekey) {
profileStatus.textContent = 'no private key to sign';
profileStatus.hidden = false;
return;
}
const sig = signEvent(newProfile, privatekey);
// TODO: validateEvent
if (sig) {
publish({...newProfile, sig}, (relay, error) => {
if (error) {
return console.error(error, relay);
}
console.info(`publish request sent to ${relay}`);
profileStatus.textContent = 'profile successfully published';
profileStatus.hidden = false;
profileSubmit.disabled = true;
});
}
});

@ -0,0 +1,336 @@
/* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */
.mbox {
align-items: center;
border-top: 1px solid var(--bgcolor-nav);
display: flex;
flex-direction: row;
flex-shrink: 0;
flex-wrap: wrap;
/* margin-bottom: 1rem; */
max-width: var(--content-width);
padding: var(--gap-half) var(--gap-half) 0 var(--gap-half);
}
.mbox:last-child {
margin-bottom: 0;
}
.mbox-img {
align-self: start;
background-color: var(--bgcolor-textinput);
border-radius: var(--profileimg-size);
flex-basis: var(--profileimg-size);
flex-shrink: 0;
height: var(--profileimg-size);
margin-right: var(--gap-half);
max-height: var(--profileimg-size);
max-width: var(--profileimg-size);
overflow: clip;
position: relative;
z-index: 2;
}
a.mbox-img:focus {
--focus-border-radius: var(--profileimg-size);
}
.mbox-img canvas,
.mbox-img img {
display: block;
}
.mbox-updated-contact .mbox-img,
.mbox-recommend-server .mbox-img {
--profileimg-size: 2rem;
margin-left: 2rem;
}
.mbox-body {
flex-basis: 100%;
flex-grow: 0;
flex-shrink: 1;
padding-bottom: var(--gap-half);
word-break: break-word;
}
.mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-basis: var(--max-width);
max-width: var(--max-width);
}
.mbox-replies .mbox-replies .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) + var(--gap-half));
}
.mbox-contact .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half) - 90px);
}
.mbox-header {
align-items: baseline;
display: flex;
gap: var(--gap-quarter);
justify-content: space-between;
margin: .1rem 0;
min-height: 1.8rem;
}
.mbox-header a {
font-size: var(--font-small);
line-height: var(--lineheight-small);
text-decoration: none;
}
.mbox-header small {
color: var(--color-accent);
white-space: nowrap;
}
.mbox-username {
}
.mbox-kind0-name {
color: var(--color-accent);
}
.mbox-contact {
align-items: start; /* TODO: maybe all .mbox element should have align-items start */
flex-wrap: nowrap;
padding: var(--gap-half);
}
.mbox-contact .mbox-header {
justify-content: start;
}
.mbox-contact .mbox-body {
flex-basis: 100%;
flex-grow: 1;
flex-shrink: 1;
min-width: 0; /* with this mbox-content displays text on one line cutting off text-overflo... */
padding-bottom: 0;
}
.mbox-contact .mbox-content {
overflow: clip;
padding-right: var(--gap-quarter);
text-overflow: ellipsis;
white-space: nowrap;
}
.mbox-cta {
align-items: center;
align-self: center;
display: flex;
white-space: nowrap;
}
.mbox-updated-contact,
.mbox-recommend-server {
padding-bottom: var(--gap-quarter);
}
.mbox-updated-contact .mbox-body,
.mbox-recommend-server .mbox-body {
display: block;
font-size: var(--font-small);
padding-bottom: var(--gap-quarter);
padding-top: 0;
}
.mbox-updated-contact + .mbox-updated-contact,
.mbox-recommend-server + .mbox-updated-contact {
padding-top: 0;
}
.mbox-updated-contact .mbox-header,
.mbox-recommend-server .mbox-header {
display: inline;
}
.mbox-content {
max-width: 100%;
}
.mbox-updated-contact {
margin: 0;
}
.mbox-updated-contact + .mbox-updated-contact {
border-top: none;
}
.mbox {
overflow: clip;
}
.mbox .mbox {
border-top: none;
max-width: 100%;
overflow: visible;
padding: 0;
position: relative;
}
.mbox button:not(#publish) {
--bg-color: none;
--border-color: none;
}
.mbox button img + small:not(:empty) {
padding-left: .5rem;
}
.mbox-replies {
box-sizing: border-box;
flex-basis: 100%;
flex-grow: 1;
flex-shrink: 0;
position: relative;
}
.mbox-replies .mbox-replies {
--reply-padding: 3rem;
margin-bottom: 2px;
padding: 0 0 0 var(--reply-padding);
}
.mbox-replies .mbox-replies .mbox-replies {
--reply-padding: 0;
}
/* direct replies, test with http://localhost:8001/note1aer8780aywqqlfgjjch75uqmkujcdj4edv009nuludwq7w46lkvssclaxx */
.mbox-replies .mbox-replies .mbox:first-child::before {
background: none;
border-color: var(--bgcolor-inactive);;
border-style: solid;
border-width: 0 0 .2rem .2rem;
content: "";
display: block;
height: var(--profileimg-size-quarter);
left: calc(-1 * var(--profileimg-size-quarter));
margin-left: -.1rem;
position: absolute;
top: 0;
width: .8rem;
}
.mbox-replies .mbox-replies .mbox-replies .mbox::before {
content: none;
display: none;
}
/* .mbox-replies .mbox-replies .mbox-replies .mbox::after, */
.mbox-replies .mbox-replies .mbox-replies::before {
content: none;
}
.mbox-body,
.mbox-has-replies:not(:last-child) {
position: relative;
}
.mbox-has-replies > .mbox-body::after,
.mbox-replies .mbox-has-replies:not(:last-child)::after,
.mbox-has-replies:not(:last-child) .mbox > .mbox-body::after,
.mbox-has-replies .mbox:not(:last-child) > .mbox-body::after {
bottom: 0;
content: "";
display: block;
position: absolute;
top: .2rem;
width: .2rem;
}
.mbox-body::after,
.mbox-has-replies::after {
background: var(--bgcolor-inactive);
}
.mbox-has-replies .mbox:not(:last-child) > .mbox-body::after {
left: -33px;
}
/* test with http://localhost:8001/note1aer8780aywqqlfgjjch75uqmkujcdj4edv009nuludwq7w46lkvssclaxx */
.mbox-has-replies .mbox-has-replies .mbox:not(:last-child) > .mbox-body::after {
left: -18px;
}
.mbox-has-replies > .mbox-body::after {
left: -33px;
}
.mbox-has-replies:not(:last-child) .mbox > .mbox-body::after {
left: -33px;
}
.mbox-has-replies:not(:last-child)::after {
left: 18px;
}
.mbox-replies .mbox-has-replies:not(:last-child)::after {
left: 19px;
}
/* test with http://localhost:8001/note1quq9waqtrgl97v3gutg4au4zthetz853u8knsnz65nrqhavxvq8qrp6lyg */
.mbox-replies .mbox-replies .mbox-has-replies:not(:last-child)::after {
left: 7px;
}
.mbox-replies .mbox-replies .mbox-has-replies > .mbox-body::after {
left: -18px;
}
.mbox-has-replies .mbox-has-replies .mbox-has-replies .mbox:not(:last-child) > .mbox-body::after {
left: -18px;
}
.mbox-replies .mbox:not(.mbox-has-replies):last-child > .mbox-body::after {
content: none;
display: none;
}
.mbox-replies .mbox-replies .mbox-has-replies.mbox:not(:last-child) > .mbox-body::after {
left: -18px;
}
.mbox-replies .mbox .mbox .mbox-img {
--profileimg-size: 2rem;
left: -.2rem;
margin-right: .5rem;
margin-top: .2rem;
position: relative;
}
/*
.mbox-replies .mbox-body.mbox-oneline {
display: flex;
flex-wrap: wrap;
font-size: var(--font-small);
padding-bottom: var(--gap-half);
padding-top: var(--gap-eight);
}
@media (orientation: portrait) {
.mbox-replies .mbox .mbox .mbox-body {
}
}
*/
.mbox-replies .mbox .mbox .buttons {
display: none;
}
[data-append]::after {
color: var(--color-accent);
content: "…";
}
.preview-loaded a {
background-color: var(--bgcolor-textinput);
border: 1px solid var(--bgcolor-inactive);
color: var(--color);
display: flex;
flex-direction: column;
margin: var(--gap) 0 0 0;
max-width: 48rem;
padding: 1.5rem 1.8rem;
text-decoration: none;
}
.preview-loaded a:visited {
color: inherit;
}
.preview-title {
font-size: inherit;
margin: 0;
}
.preview-descr {
font-size: var(--font-small);
}
.preview-image {
background-color: rgba(72, 63, 63, 0.07);
margin-bottom: var(--gap);
max-height: 30vh;
object-fit: contain;
}
.preview-image-only {
background-color: var(--bgcolor-textinput);
border: 1px solid var(--bgcolor-inactive);
margin: var(--gap) 0 0 0;
max-width: 48rem;
padding: 1.5rem 1.8rem;
width: 100%;
}

@ -0,0 +1,24 @@
.mbox-img {
opacity: .73;
}
.mbox-replies {
margin-bottom: 4px !important;
outline: 2px dashed rgba(0, 255, 0, 0.15);
outline-offset: -2px;
}
.mbox-replies .mbox-replies {
outline-color: rgba(0, 255, 0, .4);
outline-offset: -4px;
}
.mbox-replies .mbox-replies .mbox-replies {
outline-color: rgba(0, 255, 0, .7);
outline-offset: -6px;
}
.mbox-replies .mbox-replies .mbox-replies .mbox-replies {
outline-color: rgba(210, 255, 0, 1);
outline-offset: -8px;
}
.mbox-replies .mbox-replies .mbox-replies .mbox-replies .mbox-replies {
outline-color: rgb(187, 119, 9);
}

@ -0,0 +1,39 @@
#errorOverlay {
background: var(--bgcolor-danger);
bottom: 0;
color: var(--color-danger);
display: flex;
flex-direction: column;
left: 0;
max-width: 100vw;
overflow: auto;
padding: var(--gap);
position: fixed;
right: 0;
top: 0;
z-index: 100;
}
.error-title {
margin-top: 0;
}
#errorOverlay button {
background-color: var(--bgcolor-danger-input);
border: none;
color: var(--color-danger);
display: inline-block;
}
#errorOverlay button:focus {
outline: 2px solid white;
outline-offset: var(--focus-outline-offset);
}
#errorOverlay .buttons {
max-width: var(--content-width);
}
@media (orientation: portrait) {
#errorOverlay .buttons {
flex-basis: 4rem;
}
}

@ -8,7 +8,8 @@ form,
--padding: 1.2rem; --padding: 1.2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap); max-width: var(--content-width);
padding: 0 var(--gap);
} }
fieldset { fieldset {
@ -21,7 +22,7 @@ legend {
display: none; display: none;
width: 100%; width: 100%;
} }
#newMessage legend { #newNote legend {
display: block; display: block;
} }
@ -43,6 +44,7 @@ label {
transition: background-color var(--transition-duration); transition: background-color var(--transition-duration);
} }
input[type="number"],
input[type="password"], input[type="password"],
input[type="text"], input[type="text"],
input[type="url"], input[type="url"],
@ -51,9 +53,14 @@ textarea {
border: .2rem solid #b7b7b7; border: .2rem solid #b7b7b7;
border-radius: .2rem; border-radius: .2rem;
display: block; display: block;
margin: 0 0 1.2rem 0; margin: 0 0 var(--gap-half) 0;
padding: var(--padding); padding: var(--padding);
} }
label.number,
input[type="range"] {
margin: 0 0 var(--gap) 0;
}
input[type="number"]:focus,
input[type="password"]:focus, input[type="password"]:focus,
input[type="text"]:focus, input[type="text"]:focus,
input[type="url"]:focus, input[type="url"]:focus,
@ -76,17 +83,17 @@ textarea {
textarea:focus { textarea:focus {
min-height: 3.5rem; min-height: 3.5rem;
} }
#newMessage textarea { #newNote textarea {
min-height: 10rem; min-height: 10rem;
} }
#newMessage textarea:focus { #newNote textarea:focus {
min-height: 18rem; min-height: 18rem;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#newMessage textarea { #newNote textarea {
min-height: 8rem; min-height: 8rem;
} }
#newMessage textarea:focus { #newNote textarea:focus {
min-height: 15rem; min-height: 15rem;
} }
} }
@ -95,50 +102,65 @@ textarea:focus {
align-items: center; align-items: center;
display: flex; display: flex;
flex-basis: 100%; flex-basis: 100%;
justify-content: flex-end;
gap: var(--gap); gap: var(--gap);
margin-top: 2rem; justify-content: start;
min-height: 3.2rem; margin: .6rem 0;
min-height: 2.2rem;
} }
form .buttons,
.form .buttons,
.form-inline .buttons { .form-inline .buttons {
flex-basis: fit-content; flex-basis: fit-content;
margin-top: 0; justify-content: end;
}
.buttons img,
.buttons small,
.buttons span {
font-weight: normal;
vertical-align: middle;
} }
button { button {
--bg-color: var(--bgcolor-accent); background-color: transparent;
--border-color: var(--bgcolor-accent); border: none;
background-color: var(--bg-color);
border: 0.2rem solid var(--border-color);
border-radius: .2rem;
cursor: pointer; cursor: pointer;
font-weight: bold;
outline-offset: 1px; outline-offset: 1px;
word-break: normal; word-break: normal;
} }
button:active {
--bg-color: rgb(13, 74, 139);
--border-color: rgb(13, 74, 139);
}
.primary,
.secondary {
border: 0.2rem solid var(--bgcolor-accent);
border-radius: .2rem;
padding: .9rem 2rem .7rem 2rem;
}
.primary {
background-color: var(--bgcolor-accent);
}
.secondary {
background-color: transparent;
}
.secondary:disabled {
border-color: var(--color-accent);
color: var(--color-accent);
}
button:focus { button:focus {
} }
.btn-inline { .btn-inline {
--border-color: transparent; --border-color: transparent;
align-items: center;
background: transparent; background: transparent;
color: var(--color); color: var(--color-accent);
display: inline-flex; display: inline-block;
gap: .5ch;
line-height: 1; line-height: 1;
padding: .6rem; padding: 0 .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 { .btn-danger {
@ -154,6 +176,7 @@ button:disabled {
.form-status { .form-status {
flex-basis: 100%; flex-basis: 100%;
flex-grow: 1; flex-grow: 1;
min-height: 1.8rem;
padding: var(--padding); padding: var(--padding);
} }
@ -169,11 +192,13 @@ button:disabled {
margin-left: var(--gap); margin-left: var(--gap);
} }
.form-inline button, .form-inline button,
.form-inline input[type="number"],
.form-inline input[type="text"], .form-inline input[type="text"],
.form-inline textarea { .form-inline textarea {
margin: .4rem 0; margin: .4rem 0;
} }
.form-inline input[type="number"],
.form-inline input[type="text"], .form-inline input[type="text"],
.form-inline textarea { .form-inline textarea {
flex-basis: 50%; flex-basis: 50%;
@ -187,6 +212,43 @@ button:disabled {
flex-grow: 0; flex-grow: 0;
} }
label.number {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: var(--gap);
padding: 0;
}
label.number span {
flex-grow: 1;
padding: 0 0 0 var(--padding);
}
label.number input[type="number"] {
align-self: baseline;
margin-bottom: 0;
}
@media (orientation: landscape) {
label.number span {
align-self: center;
}
label.number input[type="number"] + span {
padding: 0 var(--padding) 0 0;
}
}
@media (orientation: portrait) {
label.number {
flex-direction: column;
gap: var(--gap-half);
padding: 0;
}
label.number span {
padding: 0 var(--padding);
}
label.number input[type="number"] {
align-self: stretch;
}
}
button#publish { button#publish {
align-self: end; align-self: end;
order: 2; order: 2;
@ -194,7 +256,7 @@ button#publish {
button[name="back"] { button[name="back"] {
display: none; display: none;
} }
#newMessage button[name="back"] { #newNote button[name="back"] {
align-self: end; align-self: end;
display: inherit; display: inherit;
} }

@ -0,0 +1,194 @@
@import "view.css";
@import "cards.css";
@import "form.css";
@import "write.css";
@import "error.css";
/* @import "debug.css"; */
:root {
--content-width: min(100% - 2.4rem, 96ch);
/* 5px auto Highlight */
--focus-border-color: rgb(0, 122, 255);
--focus-border-radius: .2rem;
--focus-outline-color: rgb(192, 227, 252);
--focus-outline-offset: 2px;
--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;
--lineheight-small: 1.5;
--gap: 2.4rem;
--gap-half: 1.2rem;
--gap-quarter: .6rem;
--gap-eight: .3rem;
--profileimg-size: 4rem;
--profileimg-size-half: 2rem;
--profileimg-size-quarter: 1rem;
}
@media (orientation: portrait) {
:root {
--content-width: 100%;
}
}
::selection {
background: #ff79f9;
color: #fff;
}
:where([hidden]) {
display: none !important;
}
@media (prefers-color-scheme: light) {
html {
--color: rgb(43, 43, 43);
--color-accent: rgb(118, 118, 118);
--color-accent-line: rgb(163, 163, 163);
--color-danger: #0e0e0e;
--color-visited: #7467c4;
--color-visited-line: #9083e3;
--color-inverse: #fff;
--bgcolor: #fff;
--bgcolor-nav: gainsboro;
--bgcolor-accent: #5194ff;
--bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85);
--bgcolor-inactive: #bababa;
--bgcolor-textinput: #fff;
}
}
@media (prefers-color-scheme: dark) {
html {
--color: #d9d9d9;
--color-accent: #828282;
--color-accent-line: #737373;
--color-danger: #e3e3e3;
--color-visited: #796ae3;
--color-visited-line: #5d4fce;
--color-inverse: #101010;
--bgcolor: #101010;
--bgcolor-nav: rgb(31, 22, 51);
--bgcolor-accent: rgb(16, 77, 176);
--bgcolor-danger: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5);
--bgcolor-inactive: #353638;
--bgcolor-textinput: #0e0e0e;
}
img {
opacity: .75;
transition: opacity .5s ease-in-out;
}
img:hover {
opacity: 1;
}
}
html {
font-size: 62.5%;
line-height: 1;
}
body {
background-color: var(--bgcolor);
color: var(--color);
font-size: 1.6rem;
line-height: 1.375;
word-break: break-word;
}
@media (orientation: portrait) {
body {
font-size: 1.4rem;
line-height: 1.428571428571429;
}
}
html, body {
height: 100%;
margin: 0;
min-height: 100%;
overflow: clip;
}
h1, h2, h3, h4, h5 { font-weight: normal; }
body,
button,
input,
select,
textarea {
font-family: sans-serif;
}
small,
time {
font-size: var(--font-small);
line-height: 1.25;
}
canvas,
img {
max-width: 100%;
}
.text {
padding: 0 var(--gap);
}
.danger {
background-color: var(--bgcolor-danger);
}
a {
color: var(--color-accent);
text-decoration-color: var(--color-accent-line);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
}
a .highlight {
color: var(--color);
}
a:focus,
button:focus {
border-radius: var(--focus-border-radius);
outline: var(--focus-outline);
outline-offset: 0;
}
a:visited {
color: var(--color-visited);
text-decoration-color: var(--color-visited-line);
}
nav a:visited {
color: inherit;
}
img[alt] {
font-size: .9rem;
text-align: center;
word-break: break-all;
}
pre {
margin: 0;
padding: 0;
}
dl {
display: grid;
grid-row-gap: var(--gap-half);
grid-template-columns: max-content auto;
}
dt {
color: var(--color-accent);
grid-column-start: 1;
}
dd {
grid-column-start: 2;
}

@ -0,0 +1,203 @@
.root {
display: flex;
height: 100%;
max-height: 100%;
flex-direction: column;
}
@media (orientation: landscape) {
.root {
flex-direction: row-reverse;
}
}
main {
display: flex;
flex-grow: 1;
height: 100%;
overflow: clip;
position: relative;
width: 100%;
}
aside {
z-index: 4;
}
nav {
align-items: center;
background-color: var(--bgcolor-nav);
display: flex;
flex-direction: row;
flex-grow: 1;
flex-shrink: 0;
justify-content: space-between;
min-height: 4.6rem;
overflow-y: auto;
padding: .2rem 1.5rem;
user-select: none;
-webkit-user-select: none;
}
@supports (padding: max(0px)) {
nav {
padding-bottom: env(safe-area-inset-bottom);
}
}
@media (orientation: landscape) {
nav {
align-items: stretch;
flex-direction: column;
justify-content: space-between;
}
}
nav a,
nav button {
--bgcolor-accent: transparent;
--border-color: transparent;
border-radius: 0;
color: inherit;
font-weight: bold;
padding: 1rem;
}
@media (orientation: landscape) {
nav a,
nav button {
padding: 2rem 0;
}
nav .spacer {
flex-grow: 1;
}
nav button:last-child {
margin-bottom: .4rem;
}
}
@media (orientation: portrait) {
nav .spacer {
display: none;
}
}
.view {
background-color: var(--bgcolor);
display: flex;
flex-direction: column;
left: 0;
min-height: 100%;
opacity: 1;
overflow-x: clip;
position: absolute;
top: 0;
transform: translateX(0);
transition: transform .3s cubic-bezier(.465,.183,.153,.946);
width: 100%;
will-change: transform;
z-index: 2;
}
@media (orientation: landscape) {
.view {
transition: opacity .3s cubic-bezier(.465,.183,.153,.946);
}
}
.view.view-next {
z-index: 3;
}
.view.view-prev {
z-index: 1;
}
@media (orientation: portrait) {
.view.view-next {
transform: translateX(100%);
}
.view.view-prev {
transform: translateX(-20%);
}
}
@media (orientation: landscape) {
.view.view-next,
.view.view-next {
opacity: 0;
pointer-events: none;
}
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-inline: auto;
overflow-y: auto;
width: 100%;
}
main .content {
height: 1px;
padding-bottom: 10rem;
}
nav .content {
display: flex;
flex-direction: row;
justify-content: space-between;
}
nav a {
text-align: center;
text-decoration: none;
}
.content h1 {
padding: 0;
}
.hero {
--extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half);
}
.hero-title {
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: var(--gap-half);
justify-content: end;
max-width: var(--content-width);
}
.hero-title h1 {
flex-grow: 1;
font-size: 2.1rem;
line-height: 1.285714285714286;
margin-bottom: 0;
margin-top: 2rem;
padding-left: var(--extra-space);
}
.hero-title button {
line-height: 1;
}
.hero p {
max-width: calc(var(--content-width) - var(--extra-space));
padding-left: var(--extra-space);
}
.hero .hero-npub {
color: var(--color-accent);
display: block;
font-size: 1.1rem;
line-height: 1.36363636;
max-width: 100%;
overflow: clip;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 54ch) {
.hero .hero-npub {
padding-left: var(--extra-space);
text-align: left;
}
}
.hero footer {
display: flex;
gap: var(--gap-half);
padding-left: var(--extra-space);
}
.hero footer a {
text-decoration: none;
}

@ -1,20 +1,31 @@
#bubble { #bubble {
bottom: 4rem; background-color: darkmagenta;
border-color: darkmagenta;
border-radius: 10rem;
bottom: 5rem;
height: 10rem; height: 10rem;
padding: 0; padding: 0;
position: fixed; position: fixed;
right: 5rem; right: 5rem;
width: 10rem; width: 10rem;
z-index: 12; z-index: 1;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#bubble { #bubble {
bottom: calc(2 * var(--gap)); bottom: 7rem;
right: var(--gap); right: 3rem;
height: 7rem;
width: 7rem;
} }
} }
#bubble svg {
height: 100%;
position: relative;
width: 100%;
top: .5rem;
}
#newMessage { #newNote {
align-items: center; align-items: center;
display: flex; display: flex;
height: 100vh; height: 100vh;
@ -25,12 +36,12 @@
z-index: 20; z-index: 20;
} }
@media (orientation: portrait) { @media (orientation: portrait) {
#newMessage { #newNote {
align-items: start; align-items: start;
} }
} }
#newMessage #writeForm { #newNote #writeForm {
align-items: start; align-items: start;
background-color: var(--bgcolor); background-color: var(--bgcolor);
display: flex; display: flex;
@ -46,11 +57,11 @@
padding: 2rem; padding: 2rem;
} }
#newMessage .form-inline textarea { #newNote .form-inline textarea {
flex-basis: 100%; flex-basis: 100%;
margin: var(--gap) 0; margin: var(--gap) 0;
} }
#newMessage .buttons { #newNote .buttons {
align-self: end; align-self: end;
} }

@ -0,0 +1,328 @@
import {Event} from 'nostr-tools';
import {getReplyTo, hasEventTag, isMention, isPTag} from './events';
import {config} from './settings';
import {sub, subOnce, unsubAll} from './relays';
type SubCallback = (
event: Event,
relay: string,
) => void;
export const subPubkeys = (
pubkeys: string[],
onEvent: SubCallback,
) => {
const authorsPrefixes = pubkeys.map(pubkey => pubkey.slice(0, 32));
console.info(`subscribe to homefeed ${authorsPrefixes}`);
unsubAll();
const repliesTo = new Set<string>();
sub({
cb: (evt, relay) => {
if (
evt.tags.some(hasEventTag)
&& !evt.tags.some(isMention)
) {
const note = getReplyTo(evt); // get all reply to events instead?
if (note && !repliesTo.has(note)) {
repliesTo.add(note);
subOnce({
cb: onEvent,
filter: {
ids: [note],
kinds: [1],
limit: 1,
},
relay,
});
}
}
onEvent(evt, relay);
},
filter: {
authors: authorsPrefixes,
kinds: [1],
limit: 20,
},
});
// get metadata
sub({
cb: onEvent,
filter: {
authors: pubkeys,
kinds: [0],
limit: pubkeys.length,
},
unsub: true,
});
};
/** subscribe to global feed */
export const subGlobalFeed = (onEvent: SubCallback) => {
console.info('subscribe to global feed');
unsubAll();
const now = Math.floor(Date.now() * 0.001);
const pubkeys = new Set<string>();
const notes = new Set<string>();
const prefix = Math.floor(config.filterDifficulty / 4); // 4 bits in each '0' character
sub({ // get past events
cb: (evt, relay) => {
pubkeys.add(evt.pubkey);
notes.add(evt.id);
onEvent(evt, relay);
},
filter: {
...(prefix && {ids: ['0'.repeat(prefix)]}),
kinds: [1],
until: now,
...(!prefix && {since: Math.floor(now - (24 * 60 * 60))}),
limit: 100,
},
unsub: true
});
setTimeout(() => {
// get profile info
sub({
cb: onEvent,
filter: {
authors: Array.from(pubkeys),
kinds: [0],
limit: pubkeys.size,
},
unsub: true,
});
pubkeys.clear();
// get reactions
sub({
cb: onEvent,
filter: {
'#e': Array.from(notes),
kinds: [7],
until: now,
since: Math.floor(now - (24 * 60 * 60)),
},
unsub: true,
});
notes.clear();
}, 2000);
// subscribe to future notes, reactions and profile updates
sub({
cb: (evt, relay) => {
onEvent(evt, relay);
if (
evt.kind !== 1
|| pubkeys.has(evt.pubkey)
) {
return;
}
subOnce({ // get profil data
relay,
cb: onEvent,
filter: {
authors: [evt.pubkey],
kinds: [0],
limit: 1,
}
});
},
filter: {
...(prefix && {ids: ['0'.repeat(prefix)]}),
kinds: [0, 1, 7],
since: now,
},
});
};
/** subscribe to global feed */
// export const simpleSub24hFeed = (onEvent: SubCallback) => {
// unsubAll();
// sub({
// cb: onEvent,
// filter: {
// kinds: [0, 1, 2, 7],
// // until: Math.floor(Date.now() * 0.001),
// since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)),
// limit: 250,
// }
// });
// };
/** subscribe to a note id (nip-19) */
export const subNote = (
eventId: string,
onEvent: SubCallback,
) => {
unsubAll();
sub({
cb: onEvent,
filter: {
ids: [eventId],
kinds: [1],
limit: 1,
},
unsub: true,
});
const replies = new Set<string>();
const onReply = (evt: Event, relay: string) => {
replies.add(evt.id)
onEvent(evt, relay);
unsubAll();
sub({
cb: onEvent,
filter: {
'#e': Array.from(replies),
kinds: [1, 7],
},
unsub: true,
});
};
replies.add(eventId);
setTimeout(() => {
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll
});
}, 200);
};
/** subscribe to npub key (nip-19) */
export const subProfile = (
pubkey: string,
onEvent: SubCallback,
) => {
console.info(`subscribe to profile ${pubkey}`);
unsubAll();
sub({
cb: onEvent,
filter: {
authors: [pubkey],
kinds: [0],
limit: 1,
},
});
const repliesTo = new Set<string>();
// get notes for profile
sub({
cb: (evt, relay) => {
if (
evt.tags.some(hasEventTag)
&& !evt.tags.some(isMention)
) {
const note = getReplyTo(evt);
if (note && !repliesTo.has(note)) {
repliesTo.add(note);
subOnce({
relay,
cb: onEvent,
filter: {
ids: [note],
kinds: [1],
limit: 1,
}
});
}
}
onEvent(evt, relay);
},
filter: {
authors: [pubkey],
kinds: [1],
limit: 50,
}
});
setTimeout(() => {
// get contacts
sub({
cb: onEvent,
filter: {
authors: [pubkey, config.pubkey],
kinds: [3],
limit: 6,
},
});
}, 100);
};
export const subEventID = (
id: string,
onEvent: SubCallback,
) => {
unsubAll();
sub({
cb: onEvent,
filter: {
ids: [id],
limit: 1,
},
unsub: true,
});
sub({
cb: onEvent,
filter: {
authors: [id],
limit: 200,
},
unsub: true,
});
};
export const subOwnContacts = (onEvent: SubCallback) => {
sub({
cb: onEvent,
filter: {
authors: [config.pubkey],
kinds: [3],
limit: 1,
},
unsub: true,
});
};
export const subContactList = (
pubkey: string,
onEvent: SubCallback,
) => {
unsubAll();
const pubkeys = new Set<string>();
let newestEvent = 0;
sub({
cb: (evt: Event, relay: string) => {
if (evt.created_at <= newestEvent) {
return;
}
newestEvent = evt.created_at;
const newPubkeys = evt.tags
.filter(isPTag)
.filter(([, p]) => !pubkeys.has(p))
.map(([, p]) => {
pubkeys.add(p);
return p
});
subOnce({
cb: onEvent,
filter: {
authors: newPubkeys,
kinds: [0],
},
relay,
});
onEvent(evt, relay);
},
filter: {
authors: [pubkey],
kinds: [3],
limit: 1,
},
});
};

@ -0,0 +1,124 @@
import {Event, getEventHash, UnsignedEvent} from 'nostr-tools';
import {elem, lockScroll, unlockScroll} from './utils/dom';
const errorOverlay = document.querySelector('section#errorOverlay') as HTMLElement;
type PromptErrorOptions = {
onCancel?: () => void;
onRetry?: () => void;
};
/**
* Creates an error overlay, currently with hardcoded POW related message, this could be come a generic prompt
* @param error message
* @param options {onRetry, onCancel} callbacks
*/
const promptError = (
error: string,
options: PromptErrorOptions,
) => {
const {onCancel, onRetry} = options;
lockScroll();
errorOverlay.replaceChildren(
elem('h1', {className: 'error-title'}, error),
elem('p', {}, 'time ran out finding a proof with the desired mining difficulty. either try again, lower the mining difficulty or increase the timeout in profile settings.'),
elem('div', {className: 'buttons'}, [
onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '',
onRetry ? elem('button', {data: {action: 'again'}}, 'try again') : '',
]),
);
const handleOverlayClick = (e: MouseEvent) => {
if (e.target instanceof Element) {
const button = e.target.closest('button');
if (button) {
switch(button.dataset.action) {
case 'close':
onCancel && onCancel();
break;
case 'again':
onRetry && onRetry();
break;
}
errorOverlay.removeEventListener('click', handleOverlayClick);
errorOverlay.hidden = true;
unlockScroll();
}
}
};
errorOverlay.addEventListener('click', handleOverlayClick);
errorOverlay.hidden = false;
}
type PowEventOptions = {
difficulty: number;
statusElem: HTMLElement;
timeout: number;
};
type WorkerResponse = {
error: string;
event: Event;
};
type HashedEvent = UnsignedEvent & {
id: string;
};
/**
* run proof of work in a worker until at least the specified difficulty.
* if succcessful, the returned event contains the 'nonce' tag
* and the updated created_at timestamp.
*
* powEvent returns a rejected promise if the funtion runs for longer than timeout.
* a zero timeout makes mineEvent run without a time limit.
* a zero mining target just resolves the promise without trying to find a 'nonce'.
*/
export const powEvent = (
evt: UnsignedEvent,
options: PowEventOptions
): Promise<HashedEvent | void> => {
const {difficulty, statusElem, timeout} = options;
if (difficulty === 0) {
return Promise.resolve({
...evt,
id: getEventHash(evt),
});
}
const cancelBtn = elem('button', {className: 'btn-inline'}, [elem('small', {}, 'cancel')]);
statusElem.replaceChildren('working…', cancelBtn);
statusElem.hidden = false;
return new Promise((resolve, reject) => {
const worker = new Worker('/worker.js');
const onCancel = () => {
worker.terminate();
reject(`mining kind ${evt.kind} event canceled`);
};
cancelBtn.addEventListener('click', onCancel);
worker.onmessage = (msg: MessageEvent<WorkerResponse>) => {
worker.terminate();
cancelBtn.removeEventListener('click', onCancel);
if (msg.data.error) {
promptError(msg.data.error, {
onCancel: () => reject(`mining kind ${evt.kind} event canceled`),
onRetry: async () => {
const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn);
resolve(result);
}
})
} else {
resolve(msg.data.event);
}
};
worker.onerror = (err) => {
worker.terminate();
// promptError(msg.data.error, {});
cancelBtn.removeEventListener('click', onCancel);
reject(err);
};
worker.postMessage({event: evt, difficulty, timeout});
});
};

@ -1,76 +0,0 @@
.tabs {
flex-basis: 100%;
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;
position: absolute;
width: 0;
}
.tab + label {
background-color: var(--bgcolor-textinput);
border: none;
color: var(--color);
display: inline-block;
margin-left: var(--gap);
margin-top: var(--gap);
outline: 2px solid var(--bgcolor-accent);
padding: 1rem 1.5em;
position: relative;
top: 1px;
z-index: 11;
}
input[type="radio"]:checked + label {
background: var(--bgcolor-accent);
}
.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-content {
max-width: 96ch;
min-height: 200px;
padding: calc(.5 * var(--gap)) 0 100px 0;
}
.tabbed {
align-items: start;
display: flex;
flex-wrap: wrap;
}
@media (orientation: portrait) {
.tabbed {
align-items: start;
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
justify-content: start;
}
.tabs {
height: 100vh;
height: 100dvh;
margin-top: 0;
order: 1;
overflow: scroll;
width: 100vw;
}
.tab + label {
margin-top: calc(-3 * var(--gap));
margin-left: var(--gap);
order: 2;
}
}

@ -0,0 +1,83 @@
import {nip19} from 'nostr-tools';
import {elem} from './utils/dom';
export type DOMMap = {
[id: string]: HTMLElement
};
export type ViewTemplateOptions = {
type: 'home';
id?: string;
} | {
type: 'feed';
} | {
type: 'note';
id: string;
} | {
type: 'profile';
id: string;
} | {
type: 'contacts';
id: string;
} | {
type: 'event';
id: string;
};
export const renderViewTemplate = (options: ViewTemplateOptions) => {
const content = elem('div', {className: 'content'});
const dom: DOMMap = {};
switch (options.type) {
case 'home':
break;
case 'feed':
break;
case 'profile':
const pubkey = options.id;
const npub = nip19.npubEncode(pubkey);
const about = elem('span');
const detail = elem('p', {}, about);
const followStatus = elem('small');
const followBtn = elem('button', {
className: 'primary',
name: 'follow',
data: {'id': options.id}
}, 'follow');
const following = elem('span');
const profileHeader = elem('header', {className: 'hero'}, [
elem('small', {className: 'hero-npub'}, npub),
elem('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey),
followStatus,
followBtn,
]),
detail,
elem('footer', {}, following),
]);
dom.header = profileHeader;
dom[`about-${pubkey}`] = about;
dom[`detail-${pubkey}`] = detail;
dom.following = following;
dom[`followStatus-${pubkey}`] = followStatus;
dom[`followBtn-${pubkey}`] = followBtn;
content.append(profileHeader);
document.title = pubkey;
break;
case 'note':
break;
case 'contacts':
break;
case 'event':
const id = options.id;
content.append(
elem('header', {className: 'hero'}, [
elem('h1', {}, id),
])
);
document.title = id;
break;
}
const view = elem('section', {className: 'view'}, [content]);
return {content, dom, view};
};

@ -0,0 +1,225 @@
import {Event, nip19} from 'nostr-tools';
import {Children, elem, elemArticle, parseTextContent} from './utils/dom';
import {dateTime, formatTime} from './utils/time';
import {/*validatePow,*/ sortByCreatedAt} from './events';
import {getViewElem, getViewOptions, setViewElem} from './view';
import {config} from './settings';
import {getReactions, getReactionContents} from './reactions';
import {openWriteInput} from './write';
// import {linkPreview} from './media';
import {parseJSON} from './media';
import {getMetadata} from './profiles';
import {EventWithNip19, replyList} from './notes';
import {isFollowing} from './contacts';
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => {
timeElem.textContent = formatTime(new Date(timeElem.dateTime));
});
}, 10000);
export const createTextNote = (
evt: EventWithNip19,
relay: string,
) => {
const {img, name, userName} = getMetadata(evt.pubkey);
const replies = replyList.filter(({replyTo}) => replyTo === evt.id)
.sort(sortByCreatedAt)
.reverse();
// const isLongContent = evt.content.trimRight().length > 280;
// const content = isLongContent ? evt.content.slice(0, 280) : evt.content;
const reactions = getReactions(evt.id);
const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey);
const [content, {firstLink}] = parseTextContent(evt.content);
const time = new Date(evt.created_at * 1000);
const buttons = elem('div', {className: 'buttons'}, [
elem('button', {name: 'reply', type: 'button'}, [
elem('img', {height: 24, width: 24, src: '/assets/comment.svg'})
]),
elem('button', {name: 'star', type: 'button'}, [
elem('img', {
alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24,
src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionContents(evt.id).join(' '),
}),
elem('small', {data: {reactions: ''}}, reactions.length || ''),
]),
]);
if (localStorage.getItem('reply_to') === evt.id) {
openWriteInput(buttons, evt.id);
}
const replyFeed: Array<HTMLElement> = replies[0] ? replies.map(e => setViewElem(e.id, createTextNote(e, relay))) : [];
return elemArticle([
elem('a', {className: 'mbox-img', href: `/${evt.nip19.npub}`, tabIndex: -1}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {
className: 'mbox-header',
title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${relay}\n\nEvent-id: ${evt.id}
${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''}
${evt.content}`
}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: evt.pubkey},
href: `/${evt.nip19.npub}`,
}, name || userName),
' ',
elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))),
]),
elem('div', {className: 'mbox-content'/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, content /*[
...content,
(firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null,
]*/),
buttons,
]),
...(replies[0] ? [elem('div', {className: 'mbox-replies'}, replyFeed)] : []),
], {
className: replies.length ? 'mbox-has-replies' : '',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey, relay}
});
};
type EventWithContent = Omit<Event, 'content'> & {
content: Children
}
export const renderUpdateContact = (
evt: EventWithContent,
relay: string,
) => {
const {img, name, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
return elemArticle([
elem('div', {className: 'mbox-img'}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('span', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: evt.pubkey},
}, name || userName),
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
]),
], {
className: 'mbox-updated-contact',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey, relay}
}
);
};
export const renderRecommendServer = (evt: Event, relay: string) => {
const {img, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [
elem('small', {}, [
elem('strong', {}, userName)
]),
]),
` recommends server: ${evt.content}`,
]);
return elemArticle([
elem('div', {className: 'mbox-img'}, [img]), body
], {
className: 'mbox-recommend-server',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey}
});
};
export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(evt.pubkey);
const npub = nip19.npubEncode(evt.pubkey);
let content = (![1, 7].includes(evt.kind) && evt.content !== '') ? parseJSON(evt.content) : (evt.content || '<empty>');
switch (typeof content) {
case 'object':
content = JSON.stringify(content, null, 2);
break;
default:
content = `${content}`;
}
const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: evt.pubkey},
href: `/${npub}`,
}, name || userName),
]),
elem('dl', {}, [
elem('dt', {}, 'npub'),
elem('dd', {}, npub),
elem('dt', {}, 'created at'),
elem('dd', {}, dateTime.format(evt.created_at * 1000)),
elem('dt', {}, 'relay'),
elem('dd', {}, relay),
]),
elem('h2', {}, 'Event'),
elem('dl', {}, [
elem('dt', {}, 'id'),
elem('dd', {}, evt.id),
elem('dt', {}, 'kind'),
elem('dd', {}, evt.kind),
elem('dt', {}, 'pubkey'),
elem('dd', {}, evt.pubkey),
elem('dt', {}, 'tags count'),
elem('dd', {}, evt.tags.length),
elem('dt', {}, 'tags'),
elem('dd', {}, JSON.stringify(evt.tags)),
elem('dt', {}, 'content'),
elem('dd', {}, elem('pre', {}, content as string)),
]),
]);
return elemArticle([
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
body,
], {
className: 'mbox-plain-event',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey}
});
};
export const createContact = (pubkey: string) => {
const {about: aboutContent, img, name, userName} = getMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
const view = getViewOptions();
if (view.type !== 'contacts') {
return null;
}
const isMe = config.pubkey === pubkey;
const isCurrentUser = view.id === pubkey;
const hasContact = isFollowing(pubkey);
const followStatus = elem('small');
const followBtn = elem('button', {
className: hasContact ? 'secondary' : 'primary',
...(isMe && {disabled: true}),
name: 'follow',
data: {id: pubkey}
}, hasContact ? (isMe ? 'following' : 'unfollow') : 'follow');
const about = elem('div', {className: 'mbox-content'}, aboutContent);
setViewElem(`about-${pubkey}`, about);
setViewElem(`followStatus-${pubkey}`, followStatus);
setViewElem(`followBtn-${pubkey}`, followBtn);
return elemArticle([
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: pubkey},
href: `/${npub}`,
}, name || userName),
(isMe || isCurrentUser)
? elem('small', {}, isMe ? '(your user)' : '(current user)')
: null,
]),
about,
]),
elem('div', {className: 'mbox-cta'}, [followStatus, followBtn]),
], {
className: 'mbox-contact',
data: {pubkey},
});
};

@ -0,0 +1,8 @@
/**
* type-guarded function that tells TypeScript (in strictNullChecks mode) that you're filtering out null/undefined items.
* example: array.filter(isNotNull)
*/
export const isNotNull = <T>(item: T): item is NonNullable<T> => item != null;
// alternative
// const const isNotNull = <T>(item: T | null): item is T => item !== null;

@ -0,0 +1,24 @@
/**
* evaluate the difficulty of hex32 according to nip-13.
* @param hex32 a string of 64 chars - 32 bytes in hex representation
*/
export const zeroLeadingBitsCount = (hex32: string) => {
let count = 0;
for (let i = 0; i < 64; i += 2) {
const hexbyte = hex32.slice(i, i + 2); // grab next byte
if (hexbyte === '00') {
count += 8;
continue;
}
// reached non-zero byte; count number of 0 bits in hexbyte
const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0');
for (let b = 0; b < 8; b++) {
if (bits[b] === '1' ) {
break; // reached non-zero bit; stop
}
count += 1;
}
break;
}
return count;
};

@ -0,0 +1,201 @@
import {nip19} from 'nostr-tools';
import {isNotNull} from './array';
import {isValidURL} from './url';
type DataAttributes = {
data: {
[key: string]: string | number;
},
} & {
dataset: never, // the dataset property itself is readonly
};
type Attributes<Type> = Partial<Type & DataAttributes>;
export type Children = Array<HTMLElement | string | null> | HTMLElement | string | number | null;
/**
* 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<Node> | string | number} children
* @return HTMLElement
*/
export const elem = <Name extends keyof HTMLElementTagNameMap>(
name: Extract<Name, keyof HTMLElementTagNameMap>,
attrs?: Attributes<HTMLElementTagNameMap[Name]>,
children?: Children | undefined,
): HTMLElementTagNameMap[Name] => {
const el = document.createElement(name);
if (attrs) {
const {data, ...props} = attrs;
Object.assign(el, props);
if (data) {
Object.entries(data).forEach(([key, value]) => {
el.dataset[key] = value as string;
});
}
}
if (children != null) {
if (Array.isArray(children)) {
el.append(...children.filter(isNotNull));
} else {
switch (typeof children) {
case 'number':
el.append(`${children}`);
break;
case 'string':
el.append(children);
break;
default:
if (children instanceof Element) {
el.append(children);
break;
}
console.error(`expected element, string or number but got ${typeof children}`, children);
}
}
}
return el;
};
/** freeze global page scrolling */
export const lockScroll = () => document.body.style.overflow = 'hidden';
/** free global page scrolling */
export const unlockScroll = () => document.body.style.removeProperty('overflow');
/**
* 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.startsWith('nostr:npub') && word.length === 69) {
const npub = word.slice(6);
const {type, data} = nip19.decode(npub);
if (type === 'npub') {
return elem('a', {href: `/${npub}`, data: {profile: data}}, data.slice(6, 15))
}
}
const WORD = word.toUpperCase();
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;
const prettierWithoutSlash = url.pathname === '/';
return elem('a', {
href: url.href,
target: '_blank',
rel: 'noopener noreferrer'
}, url.href.slice(url.protocol.length + 2, prettierWithoutSlash ? -1 : undefined));
} catch (err) {
return word;
}
})
.reduce((acc, word) => [...acc, word, ' '], []);
})
.reduce((acc, words) => [...acc, ...words, elem('br')], []);
return [
parsedContent,
{firstLink}
];
};
/**
* creates a small profile image
* @param text to pass pubkey
* @returns HTMLCanvasElement | null
*/
export const elemCanvas = (text: string) => {
const canvas = elem('canvas', {
height: 80,
width: 80,
data: {pubkey: text}
});
const context = canvas.getContext('2d');
if (!context) {
return null;
}
const color = `#${text.slice(0, 6)}`;
context.fillStyle = color;
context.fillRect(0, 0, 80, 80);
context.fillStyle = '#202122';
context.fillRect(0, 50, 80, 32);
context.font = 'bold 18px monospace';
if (color === '#000000') {
context.fillStyle = '#fff';
}
context.fillText(text.slice(0, 8), 2, 46);
return canvas;
};
/**
* creates a placeholder element that animates the height to 0
* @param element to get the initial height from
* @returns HTMLDivElement
*/
export const elemShrink = (el: HTMLElement) => {
const height = el.style.height || el.getBoundingClientRect().height;
const shrink = elem('div', {className: 'shrink-out'});
shrink.style.height = `${height}px`;
shrink.addEventListener('animationend', () => shrink.remove(), {once: true});
return shrink;
};
export const updateElemHeight = (
el: HTMLInputElement | HTMLTextAreaElement
) => {
el.style.removeProperty('height');
if (el.value) {
el.style.paddingBottom = '0';
el.style.paddingTop = '0';
el.style.height = el.scrollHeight + 'px';
el.style.removeProperty('padding-bottom');
el.style.removeProperty('padding-top');
}
};
export const elemArticle = (
content: Array<HTMLElement>,
attrs: Attributes<HTMLElementTagNameMap['div']> = {},
) => {
const className = attrs.className ? `mbox ${attrs.className}` : 'mbox';
return elem('article', {...attrs, className}, content);
};

@ -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: number | undefined;
let debounce: number | undefined;
return (/*...args*/) => {
if (throttle) {
clearTimeout(debounce);
debounce = setTimeout(() => fn(/*...args*/), time);
return;
}
fn(/*...args*/);
throttle = setTimeout(() => {
clearTimeout(throttle);
}, time);
};
};
/** /**
* Intl.DateTimeFormat object * Intl.DateTimeFormat object
* *
@ -5,7 +31,7 @@
* *
* console.log(dateTime.format(new Date())); * console.log(dateTime.format(new Date()));
*/ */
export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { export const dateTime = new Intl.DateTimeFormat(navigator.language /* navigator.language */, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}); });
@ -22,7 +48,10 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */
* console.log(timeAgo(new Date(Date.now() - 10000))); * console.log(timeAgo(new Date(Date.now() - 10000)));
* *
*/ */
const timeAgo = (time, locale = 'en') => { const timeAgo = (
time: Date,
locale: string = 'en',
) => {
const relativeTime = new Intl.RelativeTimeFormat(locale, { const relativeTime = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto', numeric: 'auto',
style: 'long', style: 'long',
@ -55,7 +84,7 @@ const timeAgo = (time, locale = 'en') => {
* @param {time} date object to format * @param {time} date object to format
* @return string * @return string
*/ */
export const formatTime = (time) => { export const formatTime = (time: Date) => {
const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000)); const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000));
if (time > yesterday) { if (time > yesterday) {
return timeAgo(time); return timeAgo(time);

@ -0,0 +1,65 @@
export const getHost = (url: string) => {
try {
return new URL(url).host;
} catch(err) {
return err;
}
};
export const isHttpUrl = (url: string) => {
try {
return ['http:', 'https:'].includes(new URL(url).protocol);
} catch (err) {
return false;
}
};
export const isWssUrl = (url: string) => {
try {
return 'wss:' === new URL(url).protocol;
} catch (err) {
return false;
}
};
export const getNoxyUrl = (
type: 'data' | 'meta',
url: string,
id: string,
relay: string,
) => {
if (!isHttpUrl(url)) {
return false;
}
const link = new URL(`https://noxy.nostr.ch/${type}`);
link.searchParams.set('id', id);
link.searchParams.set('relay', relay);
link.searchParams.set('url', url);
return link;
};
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;
};

@ -0,0 +1,92 @@
import {DOMMap, renderViewTemplate, ViewTemplateOptions} from './template';
type Container = {
id: string;
options: ViewTemplateOptions,
view: HTMLElement;
content: HTMLDivElement;
dom: DOMMap;
};
const containers: Array<Container> = [];
let activeContainerIndex = -1;
export const getViewContent = () => containers[activeContainerIndex]?.content;
/**
* clears current view so it is empty and ready to be re-used.
* only clears the current view, not all views
*/
export const clearView = () => {
const domMap = containers[activeContainerIndex]?.dom;
Object.keys(domMap).forEach(eventId => delete domMap[eventId]);
getViewContent().replaceChildren();
};
/**
* get elmenet stored in internal DOMMap of the current view
* alternative to internal map in view.dom, is to use id="" attribute, however same event could be shown in different views so event.id is not unique.
*/
export const getViewElem = (id: string) => {
return containers[activeContainerIndex]?.dom[id];
};
/**
* store element in internal view.dom map using id as key
*/
export const setViewElem = (id: string, node: HTMLElement) => {
const container = containers[activeContainerIndex];
if (container) {
container.dom[id] = node;
}
return node;
};
const mainContainer = document.querySelector('main') as HTMLElement;
const createContainer = (
route: string,
options: ViewTemplateOptions,
) => {
const {content, dom, view} = renderViewTemplate(options);
const container = {id: route, options, view, content, dom};
mainContainer.append(view);
containers.push(container);
return container;
};
type GetViewOptions = () => ViewTemplateOptions;
/**
* get options for current view
* @returns {id: 'home' | 'feed' | 'profile' | 'note' | 'contacts' | 'event', id?: string}
*/
export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};
/**
* changes the current view and transitions to the view specified by route
* example:
* view('/npub0293ji3gojaed32r4r412', {type: 'feed})
*/
export const view = (
route: string,
options: ViewTemplateOptions,
) => {
const active = containers[activeContainerIndex];
const nextContainer = containers.find(c => c.id === route) || createContainer(route, options);
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');
});
active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev');
activeContainerIndex = nextContainerIndex;
});
};

@ -0,0 +1,42 @@
import {getEventHash} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto';
const mine = (event, difficulty, timeout = 5) => {
const max = 256; // arbitrary
if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > max) {
throw new Error(`difficulty must be an integer between 0 and ${max}`);
}
// continue with mining
let n = BigInt(0);
event.tags.unshift(['nonce', n.toString(), `${difficulty}`]);
const until = Math.floor(Date.now() * 0.001) + timeout;
console.time('pow');
while (true) {
const now = Math.floor(Date.now() * 0.001);
if (timeout !== 0 && (now > until)) {
console.timeEnd('pow');
throw 'timeout';
}
if (now !== event.created_at) {
event.created_at = now;
// n = BigInt(0); // could reset nonce as we have a new timestamp
}
event.tags[0][1] = (++n).toString();
const id = getEventHash(event);
if (zeroLeadingBitsCount(id) === difficulty) {
console.timeEnd('pow');
return {id, ...event};
}
}
};
addEventListener('message', (msg) => {
const {difficulty, event, timeout} = msg.data;
try {
const minedEvent = mine(event, difficulty, timeout);
postMessage({event: minedEvent});
} catch (err) {
postMessage({error: err});
}
});

@ -0,0 +1,154 @@
import {signEvent} from 'nostr-tools';
import {elemShrink, updateElemHeight} from './utils/dom';
import {powEvent} from './system';
import {config} from './settings';
import {publish} from './relays';
// form used to write and publish textnotes for replies and new notes
const writeForm = document.querySelector('#writeForm') as HTMLFormElement;
const writeInput = document.querySelector('textarea[name="message"]') as HTMLTextAreaElement;
// overlay for writing new text notes
const publishView = document.querySelector('#newNote') as HTMLElement;
const openWriteView = () => {
publishView.append(writeForm);
if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height');
}
requestAnimationFrame(() => {
updateElemHeight(writeInput);
writeInput.focus();
});
publishView.removeAttribute('hidden');
};
export const closePublishView = () => publishView.hidden = true;
export const togglePublishView = () => {
if (publishView.hidden) {
localStorage.removeItem('reply_to'); // should it forget old replyto context?
openWriteView();
} else {
publishView.hidden = true;
}
};
const appendReplyForm = (el: HTMLElement) => {
writeForm.before(elemShrink(writeInput));
writeInput.blur();
writeInput.style.removeProperty('height');
el.after(writeForm);
if (writeInput.value && !writeInput.value.trimRight()) {
writeInput.value = '';
} else {
requestAnimationFrame(() => updateElemHeight(writeInput));
}
requestAnimationFrame(() => writeInput.focus());
};
const closeWriteInput = () => writeInput.blur();
export const openWriteInput = (
button: HTMLElement,
id: string,
) => {
appendReplyForm(button.closest('.buttons') as HTMLElement);
localStorage.setItem('reply_to', id);
};
export const toggleWriteInput = (
button: HTMLElement,
id: string,
) => {
if (id && localStorage.getItem('reply_to') === id) {
closeWriteInput();
return;
}
appendReplyForm(button.closest('.buttons') as HTMLElement);
localStorage.setItem('reply_to', id);
};
// const updateWriteInputHeight = () => updateElemHeight(writeInput);
writeInput.addEventListener('focusout', () => {
const reply_to = localStorage.getItem('reply_to');
if (reply_to && writeInput.value === '') {
writeInput.addEventListener('transitionend', (event) => {
if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height
writeForm.after(elemShrink(writeInput));
writeForm.remove();
localStorage.removeItem('reply_to');
}
}, {once: true});
}
});
// document.body.addEventListener('keyup', (e) => {
// if (e.key === 'Escape') {
// hideNewMessage(true);
// }
// });
const sendStatus = document.querySelector('#sendstatus') as HTMLElement;
const publishBtn = document.querySelector('#publish') as HTMLButtonElement;
const onSendError = (err: Error) => sendStatus.textContent = err.message;
writeForm.addEventListener('submit', async (e) => {
e.preventDefault();
const privatekey = localStorage.getItem('private_key');
if (!config.pubkey || !privatekey) {
return onSendError(new Error('no pubkey/privatekey'));
}
const content = writeInput.value.trimRight();
if (!content) {
return onSendError(new Error('message is empty'));
}
const replyTo = localStorage.getItem('reply_to');
const close = () => {
sendStatus.textContent = '';
writeInput.value = '';
writeInput.style.removeProperty('height');
publishBtn.disabled = true;
if (replyTo) {
localStorage.removeItem('reply_to');
publishView.append(writeForm);
}
publishView.hidden = true;
};
const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0]
const newEvent = await powEvent({
kind: 1,
content,
pubkey: config.pubkey,
tags,
created_at: Math.floor(Date.now() * 0.001),
}, {
difficulty: config.difficulty,
statusElem: sendStatus,
timeout: config.timeout,
}).catch(console.warn);
if (!newEvent) {
close();
return;
}
const sig = signEvent(newEvent, privatekey);
// TODO validateEvent
if (sig) {
sendStatus.textContent = 'publishing…';
publish({...newEvent, sig}, (relay, error) => {
if (error) {
return console.log(error, relay);
}
console.info(`publish request sent to ${relay}`);
close();
});
}
});
writeInput.addEventListener('input', () => {
publishBtn.disabled = !writeInput.value.trimRight();
updateElemHeight(writeInput);
});
writeInput.addEventListener('blur', () => sendStatus.textContent = '');

@ -0,0 +1,18 @@
{
"compilerOptions": {
"alwaysStrict": true,
"moduleResolution": "node",
"noImplicitAny": true,
"noImplicitThis": true,
"strictBindCallApply": true,
"strictFunctionTypes": false,
"strictNullChecks": true,
"target": "es2022"
},
"exclude": [
"dist",
"esbuildconf.js",
"node_modules",
"**/*.test.ts"
]
}
Loading…
Cancel
Save