diff --git a/src/main/scala/Components.scala b/src/main/scala/Components.scala new file mode 100644 index 0000000..0136520 --- /dev/null +++ b/src/main/scala/Components.scala @@ -0,0 +1,86 @@ +import cats.data.* +import cats.effect.* +import cats.effect.syntax.all.* +import cats.syntax.all.* +import fs2.concurrent.* +import fs2.dom.{Event => _, *} +import io.circe.parser.* +import io.circe.syntax.* +import calico.* +import calico.html.io.{*, given} +import calico.syntax.* +import scoin.* +import snow.* + +object Components { + def renderEventPointer( + evp: snow.EventPointer + ): Resource[IO, HtmlDivElement[IO]] = + div( + cls := "text-md", + entry("event id (hex)", evp.id), + if evp.relays.size > 0 then + Some(entry("relay hints", evp.relays.reduce((a, b) => s"$a, $b"))) + else None, + evp.author.map { pk => + entry("author hint (pubkey hex)", pk.value.toHex) + } + ) + + def renderProfilePointer( + pp: snow.ProfilePointer, + sk: Option[PrivateKey] = None + ): Resource[IO, HtmlDivElement[IO]] = + div( + cls := "text-md", + sk.map { k => entry("private key (hex)", k.value.toHex) }, + entry("public key (hex)", pp.pubkey.value.toHex), + if pp.relays.size > 0 then + Some(entry("relay hints", pp.relays.reduce((a, b) => s"$a, $b"))) + else None + ) + + def renderAddressPointer( + addr: snow.AddressPointer + ): Resource[IO, HtmlDivElement[IO]] = + div( + cls := "text-md", + entry("author (pubkey hex)", addr.author.value.toHex), + entry("identifier", addr.d), + entry("kind", addr.kind.toString), + if addr.relays.size > 0 then + Some(entry("relay hints", addr.relays.reduce((a, b) => s"$a, $b"))) + else None + ) + + def renderEvent(event: Event): Resource[IO, HtmlDivElement[IO]] = + div( + cls := "text-md", + List(("pubkey", event.pubkey), ("id", event.id), ("sig", event.sig)) + .filter((_, v) => v.isEmpty) + .map { (label, _) => entry("property missing", label) }, + entry("serialized event", event.serialized), + entry("implied event id", event.hash.toHex), + entry( + "does the implied event id match the given event id?", + event.id == Some(event.hash.toHex) match { + case true => "yes"; case false => "no" + } + ), + entry( + "is signature valid?", + event.isValid match { + case true => "yes"; case false => "no" + } + ) + ) + + private def entry( + key: String, + value: String + ): Resource[IO, HtmlDivElement[IO]] = + div( + span(cls := "font-bold", key + " "), + span(Styles.mono, value) + ) +} diff --git a/src/main/scala/Main.scala b/src/main/scala/Main.scala index ec6a5c1..33bd6fa 100644 --- a/src/main/scala/Main.scala +++ b/src/main/scala/Main.scala @@ -13,6 +13,7 @@ import scoin.* import snow.* import Utils.* +import Components.* object Store { def apply(window: Window[IO]): Resource[IO, Store] = { @@ -105,6 +106,7 @@ object Main extends IOWebApp { ( cls := "w-full max-h-96 p-3 rounded", styleAttr := "min-height: 280px; font-family: monospace", + spellCheck := false, placeholder := "paste something nostric", onInput --> (_.foreach(_ => self.value.get.flatMap(store.input.set) @@ -121,47 +123,18 @@ object Main extends IOWebApp { store.input.map { input => if input.trim() == "" then div("") else - decode[Event](input) match { - case Left(err: io.circe.ParsingFailure) => - div("not valid JSON") - case Left(err: io.circe.DecodingFailure) => - err.pathToRootString match { - case None => div(s"decoding ${err.pathToRootString}") - case Some(path) => div(s"field $path is missing or wrong") - } - case Right(event) => - div( - cls := "text-md", - div( - span(cls := "font-bold", "serialized event "), - span(Styles.mono, event.serialized) - ), - div( - span(cls := "font-bold", "implied event id "), - span(Styles.mono, event.hash.toHex) - ), - div( - span( - cls := "font-bold", - "does the implied event id match the given event id? " - ), - span( - Styles.mono, - event.id == event.hash.toHex match { - case true => "yes"; case false => "no" - } - ) - ), - div( - span(cls := "font-bold", "is signature valid? "), - span( - Styles.mono, - event.isValid match { - case true => "yes"; case false => "no" - } - ) - ) + Parser.parseInput(input) match { + case Left(msg) => div(msg) + case Right(event: Event) => + renderEvent(event) + case Right(pp: ProfilePointer) => renderProfilePointer(pp) + case Right(evp: EventPointer) => renderEventPointer(evp) + case Right(sk: PrivateKey) => + renderProfilePointer( + ProfilePointer(pubkey = sk.publicKey.xonly), + Some(sk) ) + case Right(addr: AddressPointer) => renderAddressPointer(addr) } } diff --git a/src/main/scala/Parser.scala b/src/main/scala/Parser.scala new file mode 100644 index 0000000..ff067d0 --- /dev/null +++ b/src/main/scala/Parser.scala @@ -0,0 +1,33 @@ +import io.circe.parser.* +import cats.syntax.all.* +import scoin.* +import snow.* + +import Components.* + +object Parser { + def parseInput(input: String): Either[ + String, + Event | PrivateKey | AddressPointer | EventPointer | ProfilePointer + ] = + NIP19.decode(input) match { + case Right(pp: ProfilePointer) => Right(pp) + case Right(evp: EventPointer) => Right(evp) + case Right(sk: PrivateKey) => Right(sk) + case Right(addr: AddressPointer) => Right(addr) + case Left(_) => + parse(input) match { + case Left(err: io.circe.ParsingFailure) => + Left("not valid JSON or NIP-19 code") + case Right(json) => + json + .as[Event] + .leftMap { err => + err.pathToRootString match { + case None => s"decoding ${err.pathToRootString}" + case Some(path) => s"field $path is missing or wrong" + } + } + } + } +}