mirror of
https://github.com/fiatjaf/nak.git
synced 2025-12-08 16:48:51 +00:00
477 lines
14 KiB
Scala
477 lines
14 KiB
Scala
import cats.data.{Store => *, *}
|
||
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 scodec.bits.ByteVector
|
||
import scoin.*
|
||
import snow.*
|
||
|
||
import Utils.*
|
||
|
||
object Components {
|
||
def render32Bytes(
|
||
store: Store,
|
||
bytes32: ByteVector32
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
div(
|
||
cls := "text-md",
|
||
entry("canonical hex", bytes32.toHex),
|
||
"if this is a public key:",
|
||
div(
|
||
cls := "mt-2 pl-2 mb-2",
|
||
entry(
|
||
"npub",
|
||
NIP19.encode(XOnlyPublicKey(bytes32)),
|
||
Some(
|
||
selectable(
|
||
store,
|
||
NIP19.encode(XOnlyPublicKey(bytes32))
|
||
)
|
||
)
|
||
),
|
||
nip19_21(
|
||
store,
|
||
"nprofile",
|
||
NIP19.encode(ProfilePointer(XOnlyPublicKey(bytes32)))
|
||
)
|
||
),
|
||
"if this is a private key:",
|
||
div(
|
||
cls := "pl-2 mb-2",
|
||
entry(
|
||
"nsec",
|
||
NIP19.encode(PrivateKey(bytes32)),
|
||
Some(
|
||
selectable(
|
||
store,
|
||
NIP19.encode(PrivateKey(bytes32))
|
||
)
|
||
)
|
||
),
|
||
entry(
|
||
"npub",
|
||
NIP19.encode(PrivateKey(bytes32).publicKey.xonly),
|
||
Some(
|
||
selectable(
|
||
store,
|
||
NIP19.encode(PrivateKey(bytes32).publicKey.xonly)
|
||
)
|
||
)
|
||
),
|
||
nip19_21(
|
||
store,
|
||
"nprofile",
|
||
NIP19.encode(ProfilePointer(PrivateKey(bytes32).publicKey.xonly))
|
||
)
|
||
),
|
||
"if this is an event id:",
|
||
div(
|
||
cls := "pl-2 mb-2",
|
||
nip19_21(
|
||
store,
|
||
"nevent",
|
||
NIP19.encode(EventPointer(bytes32.toHex))
|
||
)
|
||
),
|
||
div(
|
||
cls := "pl-2 mb-2",
|
||
entry(
|
||
"note",
|
||
NIP19.encode(bytes32),
|
||
Some(
|
||
selectable(
|
||
store,
|
||
NIP19.encode(bytes32)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
|
||
def renderEventPointer(
|
||
store: Store,
|
||
evp: snow.EventPointer
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
div(
|
||
cls := "text-md",
|
||
entry(
|
||
"event id (hex)",
|
||
evp.id,
|
||
Some(selectable(store, evp.id))
|
||
),
|
||
relayHints(store, evp.relays),
|
||
evp.author.map { pk =>
|
||
entry("author hint (pubkey hex)", pk.value.toHex)
|
||
},
|
||
nip19_21(store, "nevent", NIP19.encode(evp)),
|
||
entry(
|
||
"note",
|
||
NIP19.encode(ByteVector32.fromValidHex(evp.id)),
|
||
Some(selectable(store, NIP19.encode(ByteVector32.fromValidHex(evp.id))))
|
||
)
|
||
)
|
||
|
||
def renderProfilePointer(
|
||
store: Store,
|
||
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,
|
||
Some(selectable(store, k.value.toHex))
|
||
)
|
||
},
|
||
sk.map { k =>
|
||
entry(
|
||
"nsec",
|
||
NIP19.encode(k),
|
||
Some(selectable(store, NIP19.encode(k)))
|
||
)
|
||
},
|
||
entry(
|
||
"public key (hex)",
|
||
pp.pubkey.value.toHex,
|
||
Some(selectable(store, pp.pubkey.value.toHex))
|
||
),
|
||
relayHints(
|
||
store,
|
||
pp.relays,
|
||
dynamic = if sk.isDefined then false else true
|
||
),
|
||
entry(
|
||
"npub",
|
||
NIP19.encode(pp.pubkey),
|
||
Some(selectable(store, NIP19.encode(pp.pubkey)))
|
||
),
|
||
nip19_21(store, "nprofile", NIP19.encode(pp))
|
||
)
|
||
|
||
def renderAddressPointer(
|
||
store: Store,
|
||
addr: snow.AddressPointer
|
||
): Resource[IO, HtmlDivElement[IO]] = {
|
||
val nip33atag =
|
||
s"${addr.kind}:${addr.author.value.toHex}:${addr.d}"
|
||
|
||
div(
|
||
cls := "text-md",
|
||
entry("author (pubkey hex)", addr.author.value.toHex),
|
||
entry("identifier (d tag)", addr.d),
|
||
entry("kind", addr.kind.toString),
|
||
relayHints(store, addr.relays),
|
||
nip19_21(store, "naddr", NIP19.encode(addr)),
|
||
entry("nip33 'a' tag", nip33atag, Some(selectable(store, nip33atag)))
|
||
)
|
||
}
|
||
|
||
def renderEvent(
|
||
store: Store,
|
||
event: Event
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
div(
|
||
cls := "text-md",
|
||
if event.pubkey.isEmpty then
|
||
Some(
|
||
div(
|
||
cls := "flex items-center",
|
||
entry("missing", "pubkey"),
|
||
button(
|
||
Styles.buttonSmall,
|
||
"fill with a debugging key",
|
||
onClick --> (_.foreach { _ =>
|
||
store.input.set(
|
||
event
|
||
.copy(pubkey = Some(keyOne.publicKey.xonly))
|
||
.asJson
|
||
.printWith(jsonPrinter)
|
||
)
|
||
})
|
||
)
|
||
)
|
||
)
|
||
else None,
|
||
if event.id.isEmpty then
|
||
Some(
|
||
div(
|
||
cls := "flex items-center",
|
||
entry("missing", "id"),
|
||
if event.pubkey.isDefined then
|
||
Some(
|
||
button(
|
||
Styles.buttonSmall,
|
||
"fill id",
|
||
onClick --> (_.foreach(_ =>
|
||
store.input.set(
|
||
event
|
||
.copy(id = Some(event.hash.toHex))
|
||
.asJson
|
||
.printWith(jsonPrinter)
|
||
)
|
||
))
|
||
)
|
||
)
|
||
else None
|
||
)
|
||
)
|
||
else None,
|
||
if event.sig.isEmpty then
|
||
Some(
|
||
div(
|
||
cls := "flex items-center",
|
||
entry("missing", "sig"),
|
||
if event.id.isDefined && event.pubkey == Some(
|
||
keyOne.publicKey.xonly
|
||
)
|
||
then
|
||
Some(
|
||
button(
|
||
Styles.buttonSmall,
|
||
"sign",
|
||
onClick --> (_.foreach(_ =>
|
||
store.input.set(
|
||
event
|
||
.sign(keyOne)
|
||
.asJson
|
||
.printWith(jsonPrinter)
|
||
)
|
||
))
|
||
)
|
||
)
|
||
else None
|
||
)
|
||
)
|
||
else None,
|
||
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"
|
||
}
|
||
),
|
||
event.id.map(id =>
|
||
nip19_21(
|
||
store,
|
||
"nevent",
|
||
NIP19.encode(EventPointer(id, author = event.pubkey))
|
||
)
|
||
),
|
||
if event.kind >= 30000 && event.kind < 40000 then
|
||
event.pubkey
|
||
.map(author =>
|
||
nip19_21(
|
||
store,
|
||
"naddr",
|
||
NIP19.encode(
|
||
AddressPointer(
|
||
d = event.tags
|
||
.collectFirst { case "d" :: v :: _ => v }
|
||
.getOrElse(""),
|
||
kind = event.kind,
|
||
author = author,
|
||
relays = List.empty
|
||
)
|
||
)
|
||
)
|
||
)
|
||
else
|
||
event.id.map(id =>
|
||
entry(
|
||
"note",
|
||
NIP19.encode(ByteVector32.fromValidHex(id)),
|
||
Some(selectable(store, NIP19.encode(ByteVector32.fromValidHex(id))))
|
||
)
|
||
)
|
||
)
|
||
|
||
private def entry(
|
||
key: String,
|
||
value: String,
|
||
selectLink: Option[Resource[IO, HtmlSpanElement[IO]]] = None
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
div(
|
||
cls := "flex items-center space-x-3",
|
||
span(cls := "font-bold", key + " "),
|
||
span(Styles.mono, cls := "max-w-xl break-all", value),
|
||
selectLink
|
||
)
|
||
|
||
private def nip19_21(
|
||
store: Store,
|
||
key: String,
|
||
code: String
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
div(
|
||
span(cls := "font-bold", key + " "),
|
||
span(Styles.mono, cls := "break-all", code),
|
||
selectable(store, code),
|
||
a(
|
||
href := "nostr:" + code,
|
||
external
|
||
)
|
||
)
|
||
|
||
private def relayHints(
|
||
store: Store,
|
||
relays: List[String],
|
||
dynamic: Boolean = true
|
||
): Resource[IO, HtmlDivElement[IO]] =
|
||
if !dynamic && relays.isEmpty then div("")
|
||
else
|
||
SignallingRef[IO].of(false).toResource.flatMap { active =>
|
||
val value =
|
||
if relays.size > 0 then relays.reduce((a, b) => s"$a, $b") else ""
|
||
|
||
div(
|
||
cls := "flex items-center space-x-3",
|
||
span(cls := "font-bold", "relay hints "),
|
||
if relays.size == 0 then div("")
|
||
else
|
||
// displaying each relay hint
|
||
div(
|
||
cls := "flex flex-wrap max-w-xl",
|
||
relays
|
||
.map(url =>
|
||
div(
|
||
Styles.mono,
|
||
cls := "flex items-center rounded py-0.5 px-1 mr-1 mb-1 bg-orange-100",
|
||
url,
|
||
// removing a relay hint by clicking on the x
|
||
div(
|
||
cls := "cursor-pointer ml-1 text-rose-600 hover:text-rose-300",
|
||
onClick --> (_.foreach(_ => {
|
||
store.result.get.flatMap(result =>
|
||
store.input.set(
|
||
result
|
||
.map {
|
||
case a: AddressPointer =>
|
||
NIP19
|
||
.encode(
|
||
a.copy(relays =
|
||
relays.filterNot(_ == url)
|
||
)
|
||
)
|
||
case p: ProfilePointer =>
|
||
NIP19
|
||
.encode(
|
||
p.copy(relays =
|
||
relays.filterNot(_ == url)
|
||
)
|
||
)
|
||
case e: EventPointer =>
|
||
NIP19
|
||
.encode(
|
||
e.copy(relays =
|
||
relays.filterNot(_ == url)
|
||
)
|
||
)
|
||
case r => ""
|
||
}
|
||
.getOrElse("")
|
||
)
|
||
)
|
||
})),
|
||
"×"
|
||
)
|
||
)
|
||
)
|
||
)
|
||
,
|
||
active.map {
|
||
case true =>
|
||
div(
|
||
input.withSelf { self =>
|
||
(
|
||
onKeyPress --> (_.foreach(evt =>
|
||
// confirm adding a relay hint
|
||
evt.key match {
|
||
case "Enter" =>
|
||
self.value.get.flatMap(url =>
|
||
if url.startsWith("wss://") || url
|
||
.startsWith("ws://")
|
||
then {
|
||
store.result.get.flatMap(result =>
|
||
store.input.set(
|
||
result
|
||
.map {
|
||
case a: AddressPointer =>
|
||
NIP19
|
||
.encode(
|
||
a.copy(relays = a.relays :+ url)
|
||
)
|
||
case p: ProfilePointer =>
|
||
NIP19
|
||
.encode(
|
||
p.copy(relays = p.relays :+ url)
|
||
)
|
||
case e: EventPointer =>
|
||
NIP19
|
||
.encode(
|
||
e.copy(relays = e.relays :+ url)
|
||
)
|
||
case r => ""
|
||
}
|
||
.getOrElse("")
|
||
)
|
||
)
|
||
>> active.set(false)
|
||
} else IO.unit
|
||
)
|
||
case _ => IO.unit
|
||
}
|
||
))
|
||
)
|
||
}
|
||
)
|
||
case false if dynamic =>
|
||
// button to add a new relay hint
|
||
button(
|
||
Styles.buttonSmall,
|
||
"add relay hint",
|
||
onClick --> (_.foreach(_ => active.set(true)))
|
||
)
|
||
case false => div("")
|
||
}
|
||
)
|
||
}
|
||
|
||
private def selectable(
|
||
store: Store,
|
||
code: String
|
||
): Resource[IO, HtmlSpanElement[IO]] =
|
||
span(
|
||
store.input.map(current =>
|
||
if current == code then a("")
|
||
else
|
||
a(
|
||
href := "#/" + code,
|
||
onClick --> (_.foreach(evt =>
|
||
evt.preventDefault >>
|
||
store.input.set(code)
|
||
)),
|
||
edit
|
||
)
|
||
)
|
||
)
|
||
|
||
private val edit = img(cls := "inline w-4 ml-2", src := "edit.svg")
|
||
private val external = img(cls := "inline w-4 ml-2", src := "ext.svg")
|
||
}
|