diff --git a/package.json b/package.json index 87f75fe..247c4ab 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", + "@void-cat/api": "^1.0.7", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "buffer": "^6.0.3", "emoji-mart": "^5.5.2", diff --git a/public/icons.svg b/public/icons.svg index d8a86b1..94c70f7 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -30,5 +30,16 @@ + + + + + + + + + + + diff --git a/src/element/chat-message.tsx b/src/element/chat-message.tsx index 698d3f5..1a46e4f 100644 --- a/src/element/chat-message.tsx +++ b/src/element/chat-message.tsx @@ -1,5 +1,5 @@ import { useUserProfile } from "@snort/system-react"; -import { NostrEvent, parseZap, EventPublisher, EventKind } from "@snort/system"; +import { NostrEvent, parseZap, EventKind } from "@snort/system"; import { useRef, useState, useMemo } from "react"; import { useMediaQuery, @@ -18,6 +18,7 @@ import { Text } from "./text"; import { SendZapsDialog } from "./send-zap"; import { findTag } from "../utils"; import type { EmojiPack } from "../hooks/emoji"; +import { useLogin } from "../hooks/login"; interface Emoji { id: string; @@ -54,6 +55,7 @@ export function ChatMessage({ const isHovering = useHover(ref); const [showZapDialog, setShowZapDialog] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const login = useLogin(); const profile = useUserProfile( System, inView?.isIntersecting ? ev.pubkey : undefined @@ -97,7 +99,7 @@ export function ChatMessage({ setShowZapDialog(false); let reply = null; try { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (emoji.native) { reply = await pub?.react(ev, emoji.native || "+1"); } else { @@ -117,7 +119,7 @@ export function ChatMessage({ console.debug(reply); System.BroadcastEvent(reply); } - } catch (error) {} + } catch (error) { } } // @ts-expect-error @@ -176,16 +178,16 @@ export function ChatMessage({ style={ isTablet ? { - display: showZapDialog || isHovering ? "flex" : "none", - } + display: showZapDialog || isHovering ? "flex" : "none", + } : { - position: "fixed", - top: topOffset - 12, - left: leftOffset - 32, - opacity: showZapDialog || isHovering ? 1 : 0, - pointerEvents: - showZapDialog || isHovering ? "auto" : "none", - } + position: "fixed", + top: topOffset - 12, + left: leftOffset - 32, + opacity: showZapDialog || isHovering ? 1 : 0, + pointerEvents: + showZapDialog || isHovering ? "auto" : "none", + } } > {zapTarget && ( diff --git a/src/element/copy.css b/src/element/copy.css new file mode 100644 index 0000000..be437e1 --- /dev/null +++ b/src/element/copy.css @@ -0,0 +1,11 @@ +.copy { + display: flex; + cursor: pointer; + align-items: center; + gap: 8px; +} + +.copy .body { + font-size: small; + color: white; +} \ No newline at end of file diff --git a/src/element/copy.tsx b/src/element/copy.tsx new file mode 100644 index 0000000..2ccc0ba --- /dev/null +++ b/src/element/copy.tsx @@ -0,0 +1,23 @@ +import "./copy.css"; +import { useCopy } from "hooks/copy"; +import { Icon } from "./icon"; + +export interface CopyProps { + text: string; + maxSize?: number; + className?: string; +} +export default function Copy({ text, maxSize = 32, className }: CopyProps) { + const { copy, copied } = useCopy(); + const sliceLength = maxSize / 2; + const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text; + + return ( +
copy(text)}> + {trimmed} + + {copied ? : } + +
+ ); +} diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index afe6f93..8d34fce 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -1,4 +1,4 @@ -import { EventKind, EventPublisher } from "@snort/system"; +import { EventKind } from "@snort/system"; import { useLogin } from "hooks/login"; import useFollows from "hooks/follows"; import AsyncButton from "element/async-button"; @@ -12,10 +12,11 @@ export function LoggedInFollowButton({ pubkey: string; }) { const { contacts, relays } = useFollows(loggedIn, true); + const login = useLogin(); const isFollowing = contacts.find((t) => t.at(1) === pubkey); async function unfollow() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (pub) { const ev = await pub.generic((eb) => { eb.kind(EventKind.ContactList).content(JSON.stringify(relays)); @@ -32,7 +33,7 @@ export function LoggedInFollowButton({ } async function follow() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (pub) { const ev = await pub.generic((eb) => { eb.kind(EventKind.ContactList).content(JSON.stringify(relays)); diff --git a/src/element/login-signup.css b/src/element/login-signup.css new file mode 100644 index 0000000..39a199f --- /dev/null +++ b/src/element/login-signup.css @@ -0,0 +1,13 @@ +.avatar-input { + width: 90px; + height: 90px; + background-color: #aaa; + border-radius: 100%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background-image: var(--img); + background-position: center; + background-size: cover; +} \ No newline at end of file diff --git a/src/element/login-signup.tsx b/src/element/login-signup.tsx new file mode 100644 index 0000000..e55a93d --- /dev/null +++ b/src/element/login-signup.tsx @@ -0,0 +1,138 @@ +import "./login-signup.css"; +import { CSSProperties, useState } from "react"; +import { EventPublisher, UserMetadata } from "@snort/system"; +import { schnorr } from "@noble/curves/secp256k1"; +import { bytesToHex } from "@noble/curves/abstract/utils"; + +import AsyncButton from "./async-button"; +import { Login, System } from "index"; +import { Icon } from "./icon"; +import Copy from "./copy"; +import { hexToBech32, openFile } from "utils"; +import { VoidApi } from "@void-cat/api"; +import { upload } from "@testing-library/user-event/dist/upload"; +import { LoginType } from "login"; + +enum Stage { + Login = 0, + Details = 1, + SaveKey = 2 +} + +export function LoginSignup({ close }: { close: () => void }) { + const [error, setError] = useState(""); + const [stage, setStage] = useState(Stage.Login); + const [username, setUsername] = useState(""); + const [avatar, setAvatar] = useState(""); + const [key, setNewKey] = useState(""); + + async function doLogin() { + try { + const pub = await EventPublisher.nip7(); + if (pub) { + Login.loginWithPubkey(pub.pubKey, LoginType.Nip7); + } + } catch (e) { + console.error(e); + if (e instanceof Error) { + setError(e.message); + } else { + setError(e as string); + } + } + } + + function createAccount() { + const newKey = bytesToHex(schnorr.utils.randomPrivateKey()); + setNewKey(newKey); + setStage(Stage.Details); + } + + function loginWithKey() { + Login.loginWithPrivateKey(key); + close(); + } + + async function uploadAvatar() { + const file = await openFile(); + if (file) { + const VoidCatHost = "https://void.cat" + const api = new VoidApi(VoidCatHost); + const uploader = api.getUploader(file); + const result = await uploader.upload({ + "V-Strip-Metadata": "true" + }) + if (result.ok) { + const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`; + setAvatar(resultUrl); + } else { + setError(result.errorMessage ?? "Upload failed"); + } + } + } + + async function saveProfile() { + const profile = { + name: username, + picture: avatar + } as UserMetadata; + + const pub = EventPublisher.privateKey(key); + const ev = await pub.metadata(profile); + console.debug(ev); + System.BroadcastEvent(ev); + + setStage(Stage.SaveKey); + } + + switch (stage) { + case Stage.Login: { + return <> +

Login

+ {"nostr" in window && + + Nostr Extension + } + + {error && {error}} + + } + case Stage.Details: { + return <> +

Setup Profile

+
+
+ +
+
+
+
+ setUsername(e.target.value)} /> +
+ You can change this later +
+ + Save + + + } + case Stage.SaveKey: { + return <> +

Save Key

+

+ Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore! +

+
+ +
+ + + } + } +} \ No newline at end of file diff --git a/src/element/new-goal.tsx b/src/element/new-goal.tsx index 898a7ae..a2c9804 100644 --- a/src/element/new-goal.tsx +++ b/src/element/new-goal.tsx @@ -2,14 +2,12 @@ import "./new-goal.css"; import * as Dialog from "@radix-ui/react-dialog"; import AsyncButton from "./async-button"; -import { NostrLink, EventPublisher } from "@snort/system"; -import { unixNow } from "@snort/shared"; +import { NostrLink } from "@snort/system"; import { Icon } from "element/icon"; -import { useEffect, useState } from "react"; -import { eventLink } from "utils"; -import { NostrProviderDialog } from "./nostr-provider-dialog"; +import { useState } from "react"; import { System } from "index"; import { GOAL } from "const"; +import { useLogin } from "hooks/login"; interface NewGoalDialogProps { link: NostrLink; @@ -17,12 +15,13 @@ interface NewGoalDialogProps { export function NewGoalDialog({ link }: NewGoalDialogProps) { const [open, setOpen] = useState(false); + const login = useLogin(); const [goalAmount, setGoalAmount] = useState(""); const [goalName, setGoalName] = useState(""); async function publishGoal() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (pub) { const evNew = await pub.generic((eb) => { eb.kind(GOAL) diff --git a/src/element/send-zap.css b/src/element/send-zap.css index 97bb855..c203ecb 100644 --- a/src/element/send-zap.css +++ b/src/element/send-zap.css @@ -4,19 +4,6 @@ flex-direction: column; } -.send-zap h3 { - font-size: 24px; - font-weight: 500; - margin: 0; -} - -.send-zap small { - display: block; - text-transform: uppercase; - color: #868686; - margin-bottom: 12px; -} - .send-zap .amounts { display: grid; grid-template-columns: repeat(4, 1fr); @@ -37,10 +24,6 @@ background: #353535; } -.send-zap div.paper { - background: #262626; -} - .send-zap p { margin: 0 0 8px 0; font-weight: 500; diff --git a/src/element/send-zap.tsx b/src/element/send-zap.tsx index 9f1bf76..c56eb8e 100644 --- a/src/element/send-zap.tsx +++ b/src/element/send-zap.tsx @@ -11,6 +11,7 @@ import { Icon } from "./icon"; import AsyncButton from "./async-button"; import { Relays } from "index"; import QrCode from "./qr-code"; +import { useLogin } from "hooks/login"; export interface LNURLLike { get name(): string; @@ -52,6 +53,7 @@ export function SendZaps({ const [amount, setAmount] = useState(satsAmounts[0]); const [comment, setComment] = useState(""); const [invoice, setInvoice] = useState(""); + const login = useLogin(); const name = targetName ?? svc?.name; async function loadService(lnurl: string) { @@ -72,7 +74,7 @@ export function SendZaps({ async function send() { if (!svc) return; - let pub = await EventPublisher.nip7(); + let pub = login?.publisher(); let isAnon = false; if (!pub) { pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey())); diff --git a/src/element/stream-editor.tsx b/src/element/stream-editor.tsx index 6384d66..4892b11 100644 --- a/src/element/stream-editor.tsx +++ b/src/element/stream-editor.tsx @@ -1,12 +1,13 @@ import "./stream-editor.css"; import { useEffect, useState, useCallback } from "react"; -import { EventPublisher, NostrEvent } from "@snort/system"; +import { NostrEvent } from "@snort/system"; import { unixNow } from "@snort/shared"; import { TagsInput } from "react-tag-input-component"; import AsyncButton from "./async-button"; import { StreamState } from "../index"; import { findTag } from "../utils"; +import { useLogin } from "hooks/login"; export interface StreamEditorProps { ev?: NostrEvent; @@ -32,6 +33,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) { const [tags, setTags] = useState([]); const [contentWarning, setContentWarning] = useState(false); const [isValid, setIsValid] = useState(false); + const login = useLogin(); useEffect(() => { setTitle(findTag(ev, "title") ?? ""); @@ -62,7 +64,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) { }, [validate, title, summary, image, stream]); async function publishStream() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (pub) { const evNew = await pub.generic((eb) => { const now = unixNow(); @@ -83,7 +85,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) { for (const tx of tags) { eb.tag(["t", tx.trim()]); } - if(contentWarning) { + if (contentWarning) { eb.tag(["content-warning", "nsfw"]) } return eb; diff --git a/src/element/write-message.tsx b/src/element/write-message.tsx index 9d3d763..9739f93 100644 --- a/src/element/write-message.tsx +++ b/src/element/write-message.tsx @@ -1,8 +1,7 @@ -import { NostrLink, EventPublisher, EventKind } from "@snort/system"; -import { useRef, useState, useMemo, ChangeEvent } from "react"; +import { NostrLink, EventKind } from "@snort/system"; +import { useRef, useState, ChangeEvent } from "react"; import { LIVE_STREAM_CHAT } from "../const"; -import useEmoji, { packId } from "../hooks/emoji"; import { useLogin } from "../hooks/login"; import { System } from "../index"; import AsyncButton from "./async-button"; @@ -32,7 +31,7 @@ export function WriteMessage({ const leftOffset = ref.current?.getBoundingClientRect().left; async function sendChatMessage() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (chat.length > 1) { let emojiNames = new Set(); diff --git a/src/hooks/copy.tsx b/src/hooks/copy.tsx new file mode 100644 index 0000000..dde650b --- /dev/null +++ b/src/hooks/copy.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export const useCopy = (timeout = 2000) => { + const [error, setError] = useState(false); + const [copied, setCopied] = useState(false); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setError(false); + } catch (error) { + setError(true); + } + + setTimeout(() => setCopied(false), timeout); + }; + + return { error, copied, copy }; +}; diff --git a/src/hooks/login.ts b/src/hooks/login.ts index 77c8c07..d7662e6 100644 --- a/src/hooks/login.ts +++ b/src/hooks/login.ts @@ -1,9 +1,17 @@ import { Login } from "index"; +import { getPublisher } from "login"; import { useSyncExternalStore } from "react"; export function useLogin() { - return useSyncExternalStore( + const session = useSyncExternalStore( (c) => Login.hook(c), () => Login.snapshot() ); + if (!session) return; + return { + ...session, + publisher: () => { + return getPublisher(session); + } + } } diff --git a/src/index.css b/src/index.css index 24e6a3e..8ff23ef 100644 --- a/src/index.css +++ b/src/index.css @@ -33,6 +33,11 @@ a { flex-direction: column; } +.f-center { + align-items: center; + justify-content: center; +} + .pill { background: #171717; padding: 4px 8px; @@ -164,4 +169,26 @@ div.paper { .border-warning { border: 1px solid #FF563F; +} + +.dialog-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dialog-content div.paper { + background: #262626; +} + +.dialog-content h3 { + font-size: 24px; + font-weight: 500; + margin: 0; +} + +.dialog-content small { + display: block; + color: #868686; + margin: 6px; } \ No newline at end of file diff --git a/src/login.ts b/src/login.ts index 0603211..435e26e 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,7 +1,17 @@ +import { bytesToHex } from "@noble/curves/abstract/utils"; +import { schnorr } from "@noble/curves/secp256k1"; import { ExternalStore } from "@snort/shared"; +import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system"; + +export enum LoginType { + Nip7 = "nip7", + PrivateKey = "private-key" +} export interface LoginSession { + type: LoginType; pubkey: string; + privateKey?: string; follows: string[]; } @@ -13,19 +23,49 @@ export class LoginStore extends ExternalStore { const json = window.localStorage.getItem("session"); if (json) { this.#session = JSON.parse(json); + if (this.#session) { + this.#session.type ??= LoginType.Nip7; + + } } } - loginWithPubkey(pk: string) { + loginWithPubkey(pk: string, type = LoginType.Nip7) { this.#session = { + type, pubkey: pk, follows: [], }; - window.localStorage.setItem("session", JSON.stringify(this.#session)); - this.notifyChange(); + this.#save(); + } + + loginWithPrivateKey(key: string) { + this.#session = { + type: LoginType.PrivateKey, + pubkey: bytesToHex(schnorr.getPublicKey(key)), + privateKey: key, + follows: [], + }; + this.#save(); } takeSnapshot() { return this.#session ? { ...this.#session } : undefined; } + + #save() { + window.localStorage.setItem("session", JSON.stringify(this.#session)); + this.notifyChange(); + } } + +export function getPublisher(session: LoginSession) { + switch (session?.type) { + case LoginType.Nip7: { + return new EventPublisher(new Nip7Signer(), session.pubkey); + } + case LoginType.PrivateKey: { + return new EventPublisher(new PrivateKeySigner(session.privateKey!), session.pubkey); + } + } +} \ No newline at end of file diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx index 0fc762e..49fb937 100644 --- a/src/pages/layout.tsx +++ b/src/pages/layout.tsx @@ -1,27 +1,19 @@ -import { Icon } from "element/icon"; import "./layout.css"; -import { - EventPublisher, -} from "@snort/system"; +import { useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import { Outlet, useNavigate, useLocation, Link } from "react-router-dom"; -import AsyncButton from "element/async-button"; -import { Login } from "index"; + +import { Icon } from "element/icon"; import { useLogin } from "hooks/login"; import { Profile } from "element/profile"; import { NewStreamDialog } from "element/new-stream"; -import { useState } from "react"; +import { LoginSignup } from "element/login-signup"; export function LayoutPage() { const navigate = useNavigate(); const login = useLogin(); const location = useLocation(); - - async function doLogin() { - const pub = await EventPublisher.nip7(); - if (pub) { - Login.loginWithPubkey(pub.pubKey); - } - } + const [showLogin, setShowLogin] = useState(true); function loggedIn() { if (!login) return; @@ -43,14 +35,20 @@ export function LayoutPage() { function loggedOut() { if (login) return; - return ( - <> - + return + + + + + + + setShowLogin(false)} /> + + + } const isNsfw = window.location.pathname === "/nsfw"; @@ -83,6 +81,7 @@ export function LayoutPage() { {isNsfw && } + ); } diff --git a/src/pages/providers/nostr.tsx b/src/pages/providers/nostr.tsx index 1283d06..5ae5652 100644 --- a/src/pages/providers/nostr.tsx +++ b/src/pages/providers/nostr.tsx @@ -1,10 +1,11 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + import AsyncButton from "element/async-button"; import { StatePill } from "element/state-pill"; import { StreamState } from "index"; import { StreamProviderInfo, StreamProviderStore } from "providers"; import { Nip103StreamProvider } from "providers/nip103"; -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; export function ConfigureNostrType() { const [url, setUrl] = useState(""); diff --git a/src/pages/stream-page.tsx b/src/pages/stream-page.tsx index d825a46..978eb14 100644 --- a/src/pages/stream-page.tsx +++ b/src/pages/stream-page.tsx @@ -1,5 +1,5 @@ import "./stream-page.css"; -import { parseNostrLink, TaggedRawEvent, EventPublisher } from "@snort/system"; +import { parseNostrLink, TaggedRawEvent } from "@snort/system"; import { useNavigate, useParams } from "react-router-dom"; import useEventFeed from "hooks/event-feed"; @@ -31,7 +31,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) { const isMine = ev?.pubkey === login?.pubkey; async function deleteStream() { - const pub = await EventPublisher.nip7(); + const pub = login?.publisher(); if (pub && ev) { const evDelete = await pub.delete(ev.id); console.debug(evDelete); diff --git a/src/providers/nip103.ts b/src/providers/nip103.ts index 6d6b598..25cac3d 100644 --- a/src/providers/nip103.ts +++ b/src/providers/nip103.ts @@ -1,5 +1,7 @@ import { StreamProvider, StreamProviderInfo, StreamProviders } from "."; -import { EventPublisher, EventKind, NostrEvent } from "@snort/system"; +import { EventKind, NostrEvent } from "@snort/system"; +import { Login } from "index"; +import { getPublisher } from "login"; import { findTag } from "utils"; export class Nip103StreamProvider implements StreamProvider { @@ -59,8 +61,9 @@ export class Nip103StreamProvider implements StreamProvider { } async #getJson(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise { - const pub = await EventPublisher.nip7(); - if (!pub) throw new Error("No event publisher"); + const login = Login.snapshot(); + const pub = login && getPublisher(login); + if (!pub) throw new Error("No signer"); const u = `${this.#url}${path}`; const token = await pub.generic(eb => { diff --git a/src/utils.ts b/src/utils.ts index 9556d04..1bb56a3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -55,4 +55,20 @@ export function eventLink(ev: NostrEvent) { export function getHost(ev?: NostrEvent) { return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? ""; +} + +export async function openFile(): Promise { + return new Promise(resolve => { + const elm = document.createElement("input"); + elm.type = "file"; + elm.onchange = (e: Event) => { + const elm = e.target as HTMLInputElement; + if (elm.files) { + resolve(elm.files[0]); + } else { + resolve(undefined); + } + }; + elm.click(); + }); } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 3f212e5..20f7825 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -123,6 +123,7 @@ const config = { resolve: { extensions: [".tsx", ".ts", ".jsx", ".js", "..."], modules: ["node_modules", __dirname, path.resolve(__dirname, "src")], + fallback: { "crypto": false } }, }; diff --git a/yarn.lock b/yarn.lock index c432350..7948384 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2011,6 +2011,13 @@ "@typescript-eslint/types" "5.59.0" eslint-visitor-keys "^3.3.0" +"@void-cat/api@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@void-cat/api/-/api-1.0.7.tgz#39564d478dee07398826e7109d6368c68c405426" + integrity sha512-0K20PaLnRL0lYLOOn8Sk3J6THdU7ebIHWPR7S8Ytzdi5VGI8468ocackCs0b/gFZvvkwVp0X/Rygxe1/nhch+Q== + dependencies: + sjcl "^1.0.8" + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -5861,6 +5868,11 @@ sirv@^1.0.7: mrmime "^1.0.0" totalist "^1.0.0" +sjcl@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.8.tgz#f2ec8d7dc1f0f21b069b8914a41a8f236b0e252a" + integrity sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"