New login flow

This commit is contained in:
2023-09-01 12:39:04 +01:00
parent e5a67a24c5
commit 857226e2a5
15 changed files with 310 additions and 122 deletions

View File

@ -1,43 +1,50 @@
import "./login-signup.css";
import LoginHeader from "../login-start.png";
import LoginVault from "../login-vault.png";
import LoginProfile from "../login-profile.png";
import LoginKey from "../login-key.png";
import LoginWallet from "../login-wallet.png";
import { CSSProperties, useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { EventPublisher, UserMetadata } from "@snort/system";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import { LNURL, bech32ToHex, getPublicKey } from "@snort/shared";
import { VoidApi } from "@void-cat/api";
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 { FormattedMessage } from "react-intl";
import { bech32 } from "@scure/base";
import { LoginType } from "login";
import { DefaultProvider, StreamProviderInfo } from "providers";
import { Nip103StreamProvider } from "providers/zsz";
enum Stage {
Login = 0,
Details = 1,
SaveKey = 2,
LoginInput = 1,
Details = 2,
LnAddress = 3,
SaveKey = 4,
}
export function LoginSignup({ close }: { close: () => void }) {
const [error, setError] = useState("");
const [stage, setStage] = useState(Stage.Login);
const [username, setUsername] = useState("");
const [lnAddress, setLnAddress] = useState("");
const [providerInfo, setProviderInfo] = useState<StreamProviderInfo>();
const [avatar, setAvatar] = useState("");
const [key, setNewKey] = useState("");
const { formatMessage } = useIntl();
const hasNostrExtension = "nostr" in window && window.nostr;
function doLoginNsec() {
try {
let nsec = prompt("Enter your nsec\nWARNING: THIS IS NOT RECOMMENDED. DO NOT IMPORT ANY KEYS YOU CARE ABOUT");
if (!nsec) {
throw new Error("no nsec provided");
}
if (nsec.startsWith("nsec")) {
const { words } = bech32.decode(nsec, 5000);
const data = new Uint8Array(bech32.fromWords(words));
nsec = bytesToHex(data);
}
Login.loginWithPrivateKey(nsec);
const hexKey = key.startsWith("nsec") ? bech32ToHex(key) : key;
Login.loginWithPrivateKey(hexKey);
close();
} catch (e) {
console.error(e);
@ -49,9 +56,25 @@ export function LoginSignup({ close }: { close: () => void }) {
}
}
async function loginNip7() {
try {
const nip7 = await EventPublisher.nip7();
if (nip7) {
Login.loginWithPubkey(nip7.pubKey, LoginType.Nip7);
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(e as string);
}
}
}
function createAccount() {
const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
setNewKey(newKey);
setLnAddress(`${getPublicKey(newKey)}@zap.stream`)
setStage(Stage.Details);
}
@ -78,93 +101,202 @@ export function LoginSignup({ close }: { close: () => void }) {
}
}
async function setupProfile() {
const px = new Nip103StreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key));
const info = await px.info();
setProviderInfo(info);
setStage(Stage.LnAddress)
}
async function saveProfile() {
const pub = EventPublisher.privateKey(key);
const profile = {
name: username,
picture: avatar,
lud16: `${pub.pubKey}@zap.stream`,
} as UserMetadata;
try {
// validate LN addreess
try {
const lnurl = new LNURL(lnAddress);
await lnurl.load();
} catch {
throw new Error(formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong"
}));
}
const pub = EventPublisher.privateKey(key);
const profile = {
name: username,
picture: avatar,
lud16: lnAddress,
} as UserMetadata;
const ev = await pub.metadata(profile);
console.debug(ev);
System.BroadcastEvent(ev);
const ev = await pub.metadata(profile);
console.debug(ev);
System.BroadcastEvent(ev);
setStage(Stage.SaveKey);
setStage(Stage.SaveKey);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(e as string);
}
}
}
switch (stage) {
case Stage.Login: {
return (
<>
<h2>
<FormattedMessage defaultMessage="Create an Account" />
</h2>
<h3>
<FormattedMessage defaultMessage="No emails, just awesomeness!" />
</h3>
<button type="button" className="btn btn-primary btn-block" onClick={createAccount}>
<FormattedMessage defaultMessage="Create Account" />
</button>
<div className="or-divider">
<hr />
<FormattedMessage defaultMessage="OR" />
<hr />
<img src={LoginHeader} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Create an Account" />
</h2>
<h3>
<FormattedMessage defaultMessage="No emails, just awesomeness!" />
</h3>
<button type="button" className="btn btn-primary btn-block" onClick={createAccount}>
<FormattedMessage defaultMessage="Create Account" />
</button>
<div className="or-divider">
<hr />
<FormattedMessage defaultMessage="OR" />
<hr />
</div>
{hasNostrExtension && <>
<AsyncButton type="button" className="btn btn-primary btn-block" onClick={loginNip7}>
<FormattedMessage defaultMessage="Nostr Extension" />
</AsyncButton>
</>}
<button type="button" className="btn btn-primary btn-block" onClick={() => setStage(Stage.LoginInput)}>
<FormattedMessage defaultMessage="Login with Private Key (insecure)" />
</button>
{error && <b className="error">{error}</b>}
</div>
<button type="button" className="btn btn-primary btn-block" onClick={doLoginNsec}>
<FormattedMessage defaultMessage="Login with Private Key (insecure)" />
</button>
{error && <b className="error">{error}</b>}
</>
);
}
case Stage.LoginInput: {
return (
<>
<img src={LoginVault} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Login with private key" />
</h2>
<p>
<FormattedMessage defaultMessage="This method is insecure. We recommend using a {nostrlink}" values={{
nostrlink: <a href="">
<FormattedMessage defaultMessage="nostr signer extension" />
</a>
}} />
</p>
<div className="paper">
<input type="text" value={key} onChange={e => setNewKey(e.target.value)} placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz" })} />
</div>
<div className="flex f-space">
<div></div>
<div className="flex g8">
<button type="button" className="btn btn-secondary" onClick={() => {
setNewKey("");
setStage(Stage.Login)
}}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton onClick={doLoginNsec} className="btn btn-primary">
<FormattedMessage defaultMessage="Log In" />
</AsyncButton>
</div>
</div>
{error && <b className="error">{error}</b>}
</div>
</>
)
}
case Stage.Details: {
return (
<>
<h2>
<FormattedMessage defaultMessage="Setup Profile" />
</h2>
<div className="flex f-center">
<div
className="avatar-input"
onClick={uploadAvatar}
style={
{
"--img": `url(${avatar})`,
} as CSSProperties
}>
<Icon name="camera-plus" />
<img src={LoginProfile} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Setup Profile" />
</h2>
<div className="flex f-center">
<div
className="avatar-input"
onClick={uploadAvatar}
style={
{
"--img": `url(${avatar})`,
} as CSSProperties
}>
<Icon name="camera-plus" />
</div>
</div>
</div>
<div className="username">
<div className="paper">
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<div className="username">
<div className="paper">
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
</div>
<small>
<FormattedMessage defaultMessage="You can change this later" />
</small>
</div>
<small>
<FormattedMessage defaultMessage="You can change this later" />
</small>
<AsyncButton type="button" className="btn btn-primary" onClick={setupProfile}>
<FormattedMessage defaultMessage="Save" />
</AsyncButton>
</div>
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
<FormattedMessage defaultMessage="Save" />
</AsyncButton>
</>
);
}
case Stage.LnAddress: {
return (
<>
<img src={LoginWallet} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Get paid by viewers" />
</h2>
<p>
<FormattedMessage defaultMessage="We hooked you up with a lightning wallet so you can get paid by viewers right away!" />
</p>
{providerInfo?.balance && <p>
<FormattedMessage defaultMessage="Oh, and you have {n} sats of free streaming on us! 💜" values={{
n: <FormattedNumber value={providerInfo.balance} />
}} />
</p>}
<div className="username">
<div className="paper">
<input type="text" placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com" })} value={lnAddress} onChange={e => setLnAddress(e.target.value)} />
</div>
<small>
<FormattedMessage defaultMessage="You can always replace it with your own address later." />
</small>
</div>
{error && <b className="error">{error}</b>}
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
<FormattedMessage defaultMessage="Amazing! Continue.." />
</AsyncButton>
</div>
</>
)
}
case Stage.SaveKey: {
return (
<>
<h2>
<FormattedMessage defaultMessage="Save Key" />
</h2>
<p>
<FormattedMessage defaultMessage="Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!" />
</p>
<div className="paper">
<Copy text={hexToBech32("nsec", key)} />
<img src={LoginKey} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Save Key" />
</h2>
<p>
<FormattedMessage defaultMessage="Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!" />
</p>
<div className="paper">
<Copy text={hexToBech32("nsec", key)} />
</div>
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
<FormattedMessage defaultMessage="Ok, it's safe" />
</button>
</div>
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
<FormattedMessage defaultMessage="Ok, it's safe" />
</button>
</>
);
}

View File

@ -101,8 +101,10 @@ export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps)
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<div className="new-stream">
<NewStream {...props} onFinish={() => setOpen(false)} />
<div className="content-inner">
<div className="new-stream">
<NewStream {...props} onFinish={() => setOpen(false)} />
</div>
</div>
</Dialog.Content>
</Dialog.Portal>

View File

@ -61,6 +61,10 @@ a {
justify-content: center;
}
.f-space {
justify-content: space-between;
}
.pill {
background: #171717;
padding: 4px 8px;
@ -108,7 +112,6 @@ a {
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
}
.btn-block {
@ -232,7 +235,6 @@ div.paper {
.dialog-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
background-color: #171717;
@ -242,7 +244,7 @@ div.paper {
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 430px;
max-width: 450px;
max-height: 85vh;
overflow-y: auto;
}
@ -254,7 +256,6 @@ div.paper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 25px;
box-sizing: border-box;
gap: 16px;

View File

@ -2,12 +2,18 @@
"+0zv6g": {
"defaultMessage": "Image"
},
"+AcVD+": {
"defaultMessage": "No emails, just awesomeness!"
},
"+vVZ/G": {
"defaultMessage": "Connect"
},
"/0TOL5": {
"defaultMessage": "Amount"
},
"/EvlqN": {
"defaultMessage": "nostr signer extension"
},
"/GCoTA": {
"defaultMessage": "Clear"
},
@ -20,6 +26,9 @@
"1EYCdR": {
"defaultMessage": "Tags"
},
"1qsXCO": {
"defaultMessage": "eg. name@wallet.com"
},
"2/2yg+": {
"defaultMessage": "Add"
},
@ -32,9 +41,15 @@
"3adEeb": {
"defaultMessage": "{n} viewers"
},
"3df560": {
"defaultMessage": "Login with private key"
},
"47FYwb": {
"defaultMessage": "Cancel"
},
"4l69eO": {
"defaultMessage": "Hmm, your lightning address looks wrong"
},
"4l6vz1": {
"defaultMessage": "Copy"
},
@ -83,12 +98,21 @@
"ESyhzp": {
"defaultMessage": "Your comment for {name}"
},
"FjDlus": {
"defaultMessage": "You can always replace it with your own address later."
},
"Fodi9+": {
"defaultMessage": "Get paid by viewers"
},
"G/yZLu": {
"defaultMessage": "Remove"
},
"Gq6x9o": {
"defaultMessage": "Cover Image"
},
"H/bNs9": {
"defaultMessage": "Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!"
},
"H5+NAX": {
"defaultMessage": "Balance"
},
@ -101,6 +125,9 @@
"IJDKz3": {
"defaultMessage": "Zap amount in {currency}"
},
"INlWvJ": {
"defaultMessage": "OR"
},
"JEsxDw": {
"defaultMessage": "Uploading..."
},
@ -119,9 +146,6 @@
"KkIL3s": {
"defaultMessage": "No, I am under 18"
},
"Ld5LAE": {
"defaultMessage": "Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!"
},
"LknBsU": {
"defaultMessage": "Stream Key"
},
@ -137,6 +161,9 @@
"OWgHbg": {
"defaultMessage": "Edit card"
},
"Oxqtyf": {
"defaultMessage": "We hooked you up with a lightning wallet so you can get paid by viewers right away!"
},
"Q3au2v": {
"defaultMessage": "About {estimate}"
},
@ -182,6 +209,9 @@
"X2PZ7D": {
"defaultMessage": "Create Goal"
},
"Z8ZOEY": {
"defaultMessage": "This method is insecure. We recommend using a {nostrlink}"
},
"ZmqxZs": {
"defaultMessage": "You can change this later"
},
@ -203,12 +233,18 @@
"ebmhes": {
"defaultMessage": "Nostr Extension"
},
"f6biFA": {
"defaultMessage": "Oh, and you have {n} sats of free streaming on us! 💜"
},
"fBI91o": {
"defaultMessage": "Zap"
},
"fc2iho": {
"defaultMessage": "Add File"
},
"feZ/kG": {
"defaultMessage": "Login with Private Key (insecure)"
},
"hGQqkW": {
"defaultMessage": "Schedule"
},
@ -263,6 +299,9 @@
"pO/lPX": {
"defaultMessage": "Scheduled for {date}"
},
"r2Jjms": {
"defaultMessage": "Log In"
},
"rWBFZA": {
"defaultMessage": "Sexually explicit material ahead!"
},
@ -284,12 +323,18 @@
"tG1ST3": {
"defaultMessage": "Incoming Zap"
},
"tM6fNW": {
"defaultMessage": "Amazing! Continue.."
},
"thsiMl": {
"defaultMessage": "terms and conditions"
},
"tzMNF3": {
"defaultMessage": "Status"
},
"u6uD94": {
"defaultMessage": "Create an Account"
},
"uYw2LD": {
"defaultMessage": "Stream"
},
@ -314,6 +359,9 @@
"x82IOl": {
"defaultMessage": "Mute"
},
"yzKwBQ": {
"defaultMessage": "eg. nsec1xyz"
},
"zVDHAu": {
"defaultMessage": "Zap Alert"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

BIN
src/login-key.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

BIN
src/login-profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
src/login-start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

BIN
src/login-vault.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

BIN
src/login-wallet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

View File

@ -13,9 +13,6 @@ import { Menu, MenuItem } from "@szhsin/react-menu";
import { hexToBech32 } from "@snort/shared";
import { Login } from "index";
import { FormattedMessage } from "react-intl";
import { EventPublisher } from "@snort/system";
import { LoginType } from "login";
import LoginHeader from "../login-header.png";
export function LayoutPage() {
const navigate = useNavigate();
@ -65,16 +62,7 @@ export function LayoutPage() {
function loggedOut() {
if (login) return;
async function handleLogin() {
try {
const pub = await EventPublisher.nip7();
if (pub) {
Login.loginWithPubkey(pub.pubKey, LoginType.Nip7);
return;
}
} catch (e) {
console.error(e);
}
function handleLogin() {
setShowLogin(true);
}
@ -87,10 +75,7 @@ export function LayoutPage() {
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<img src={LoginHeader} className="header-image" />
<div className="content-inner">
<LoginSignup close={() => setShowLogin(false)} />
</div>
<LoginSignup close={() => setShowLogin(false)} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -15,7 +15,7 @@ export function ConfigureNostrType() {
async function tryConnect() {
try {
const api = new Nip103StreamProvider(url);
const api = new Nip103StreamProvider(new URL(url).host, url);
const inf = await api.info();
setInfo(inf);
} catch (e) {
@ -58,7 +58,7 @@ export function ConfigureNostrType() {
<button
className="btn btn-border"
onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url));
StreamProviderStore.add(new Nip103StreamProvider(new URL(url).host, url));
navigate("/");
}}>
<FormattedMessage defaultMessage="Save" />

View File

@ -73,6 +73,8 @@ export interface StreamProviderStreamInfo {
content_warning: string;
}
export const DefaultProvider = new Nip103StreamProvider("zap.stream", "https://api.zap.stream/api/nostr/");
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
#providers: Array<StreamProvider> = [];
@ -88,7 +90,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
break;
}
case StreamProviders.NostrType: {
this.#providers.push(new Nip103StreamProvider(c.url as string));
this.#providers.push(new Nip103StreamProvider(new URL(c.url as string).host, c.url as string));
break;
}
case StreamProviders.Owncast: {
@ -107,8 +109,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
}
takeSnapshot() {
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
return [defaultProvider, new ManualProvider(), ...this.#providers];
return [DefaultProvider, new ManualProvider(), ...this.#providers];
}
#save() {

View File

@ -1,3 +1,4 @@
import { base64 } from "@scure/base";
import {
StreamProvider,
StreamProviderEndpoint,
@ -5,20 +6,16 @@ import {
StreamProviderStreamInfo,
StreamProviders,
} from ".";
import { EventKind, NostrEvent } from "@snort/system";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
import { Login, StreamState } from "index";
import { getPublisher } from "login";
import { findTag } from "utils";
export class Nip103StreamProvider implements StreamProvider {
#url: string;
#publisher?: EventPublisher;
constructor(url: string) {
this.#url = url;
}
get name() {
return new URL(this.#url).host;
constructor(readonly name: string, readonly url: string, pub?: EventPublisher) {
this.#publisher = pub;
}
get type() {
@ -52,7 +49,7 @@ export class Nip103StreamProvider implements StreamProvider {
createConfig() {
return {
type: StreamProviders.NostrType,
url: this.#url,
url: this.url,
};
}
@ -83,11 +80,17 @@ export class Nip103StreamProvider implements StreamProvider {
}
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
const login = Login.snapshot();
const pub = login && getPublisher(login);
const pub = (() => {
if (this.#publisher) {
return this.#publisher;
} else {
const login = Login.snapshot();
return login && getPublisher(login);
}
})();
if (!pub) throw new Error("No signer");
const u = `${this.#url}${path}`;
const u = `${this.url}${path}`;
const token = await pub.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).content("").tag(["u", u]).tag(["method", method]);
});
@ -96,7 +99,7 @@ export class Nip103StreamProvider implements StreamProvider {
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
authorization: `Nostr ${btoa(JSON.stringify(token))}`,
authorization: `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(token)))}`,
},
});
const json = await rsp.text();

View File

@ -1,16 +1,21 @@
{
"+0zv6g": "Image",
"+AcVD+": "No emails, just awesomeness!",
"+vVZ/G": "Connect",
"/0TOL5": "Amount",
"/EvlqN": "nostr signer extension",
"/GCoTA": "Clear",
"04lmFi": "Save Key",
"0GfNiL": "Stream Zap Goals",
"1EYCdR": "Tags",
"1qsXCO": "eg. name@wallet.com",
"2/2yg+": "Add",
"2CGh/0": "live",
"3HwrQo": "Zap!",
"3adEeb": "{n} viewers",
"3df560": "Login with private key",
"47FYwb": "Cancel",
"4l69eO": "Hmm, your lightning address looks wrong",
"4l6vz1": "Copy",
"4uI538": "Resolutions",
"5JcXdV": "Create Account",
@ -27,24 +32,28 @@
"C81/uG": "Logout",
"Dn82AL": "Live",
"ESyhzp": "Your comment for {name}",
"FjDlus": "You can always replace it with your own address later.",
"Fodi9+": "Get paid by viewers",
"G/yZLu": "Remove",
"Gq6x9o": "Cover Image",
"H/bNs9": "Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!",
"H5+NAX": "Balance",
"HAlOn1": "Name",
"I1kjHI": "Supports {markdown}",
"IJDKz3": "Zap amount in {currency}",
"INlWvJ": "OR",
"JEsxDw": "Uploading...",
"Jq3FDz": "Content",
"K3r6DQ": "Delete",
"K3uH1C": "offline",
"K7AkdL": "Show",
"KkIL3s": "No, I am under 18",
"Ld5LAE": "Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!",
"LknBsU": "Stream Key",
"My6HwN": "Ok, it's safe",
"O2Cy6m": "Yes, I am over 18",
"OKhRC6": "Share",
"OWgHbg": "Edit card",
"Oxqtyf": "We hooked you up with a lightning wallet so you can get paid by viewers right away!",
"Q3au2v": "About {estimate}",
"QRHNuF": "What are we steaming today?",
"QRRCp0": "Stream URL",
@ -60,6 +69,7 @@
"VA/Z1S": "Hide",
"W9355R": "Unmute",
"X2PZ7D": "Create Goal",
"Z8ZOEY": "This method is insecure. We recommend using a {nostrlink}",
"ZmqxZs": "You can change this later",
"acrOoz": "Continue",
"cPIKU2": "Following",
@ -67,8 +77,10 @@
"cyR7Kh": "Back",
"dVD/AR": "Top Zappers",
"ebmhes": "Nostr Extension",
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
"fBI91o": "Zap",
"fc2iho": "Add File",
"feZ/kG": "Login with Private Key (insecure)",
"hGQqkW": "Schedule",
"hpl4BP": "Chat Widget",
"ieGrWo": "Follow",
@ -87,6 +99,7 @@
"oHPB8Q": "Zap {name}",
"oZrFyI": "Stream type should be HLS",
"pO/lPX": "Scheduled for {date}",
"r2Jjms": "Log In",
"rWBFZA": "Sexually explicit material ahead!",
"rbrahO": "Close",
"rfC1Zq": "Save card",
@ -94,8 +107,10 @@
"s5ksS7": "Image Link",
"s7V+5p": "Confirm your age",
"tG1ST3": "Incoming Zap",
"tM6fNW": "Amazing! Continue..",
"thsiMl": "terms and conditions",
"tzMNF3": "Status",
"u6uD94": "Create an Account",
"uYw2LD": "Stream",
"vrTOHJ": "{amount} sats",
"w0Xm2F": "Start typing",
@ -104,5 +119,6 @@
"wOy57k": "Add stream goal",
"wzWWzV": "Top zappers",
"x82IOl": "Mute",
"yzKwBQ": "eg. nsec1xyz",
"zVDHAu": "Zap Alert"
}
}