feat: text to speech and animation for zap alerts
This commit is contained in:
parent
15be10aa02
commit
29b208b136
@ -142,12 +142,16 @@ a {
|
||||
.btn-border {
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
background:
|
||||
linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.btn-border:hover {
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
background:
|
||||
linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
@ -71,6 +71,12 @@
|
||||
"6Z2pvJ": {
|
||||
"defaultMessage": "Stream Providers"
|
||||
},
|
||||
"6pr6hJ": {
|
||||
"defaultMessage": "Minimum amount for text to speech"
|
||||
},
|
||||
"8YT6ja": {
|
||||
"defaultMessage": "Insert text to speak"
|
||||
},
|
||||
"9WRlF4": {
|
||||
"defaultMessage": "Send"
|
||||
},
|
||||
@ -86,6 +92,9 @@
|
||||
"Atr2p4": {
|
||||
"defaultMessage": "NSFW Content"
|
||||
},
|
||||
"AukrPM": {
|
||||
"defaultMessage": "No viewer data available"
|
||||
},
|
||||
"AyGauy": {
|
||||
"defaultMessage": "Login"
|
||||
},
|
||||
@ -248,6 +257,9 @@
|
||||
"cyR7Kh": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"d5zWyh": {
|
||||
"defaultMessage": "Test voice"
|
||||
},
|
||||
"dVD/AR": {
|
||||
"defaultMessage": "Top Zappers"
|
||||
},
|
||||
@ -269,6 +281,9 @@
|
||||
"hGQqkW": {
|
||||
"defaultMessage": "Schedule"
|
||||
},
|
||||
"heyxZL": {
|
||||
"defaultMessage": "Enable text to speech"
|
||||
},
|
||||
"hpl4BP": {
|
||||
"defaultMessage": "Chat Widget"
|
||||
},
|
||||
@ -299,6 +314,9 @@
|
||||
"ljmS5P": {
|
||||
"defaultMessage": "Endpoint"
|
||||
},
|
||||
"mnJYBQ": {
|
||||
"defaultMessage": "Voice"
|
||||
},
|
||||
"mtNGwh": {
|
||||
"defaultMessage": "A short description of the content"
|
||||
},
|
||||
@ -341,6 +359,9 @@
|
||||
"s7V+5p": {
|
||||
"defaultMessage": "Confirm your age"
|
||||
},
|
||||
"sInm1h": {
|
||||
"defaultMessage": "Zap message"
|
||||
},
|
||||
"tG1ST3": {
|
||||
"defaultMessage": "Incoming Zap"
|
||||
},
|
||||
@ -371,6 +392,9 @@
|
||||
"wEQDC6": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"wMKVFz": {
|
||||
"defaultMessage": "Select voice..."
|
||||
},
|
||||
"wOy57k": {
|
||||
"defaultMessage": "Add stream goal"
|
||||
},
|
||||
|
@ -1,49 +1,83 @@
|
||||
.zap-alert-widgets .zap-alert {
|
||||
animation: cssAnimation 0s ease-in 10s forwards;
|
||||
animation-fill-mode: forwards;
|
||||
.zap-alerts-widget .zap-alert {
|
||||
animation: fadeInOut 10s;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
84% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-to-speech-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-to-speech-settings .labeled-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.text-to-speech-settings .labeled-input label {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-to-speech-settings textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.zap-alert {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-radius: 17px;
|
||||
background: #2d2d2d;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.zap-alert > div:nth-of-type(1) {
|
||||
.zap-alert > .zap-alert-title {
|
||||
width: fit-content;
|
||||
text-align: center;
|
||||
left: 120px;
|
||||
margin-top: -20px;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
border-radius: 33px;
|
||||
background: linear-gradient(135deg, #882bff 0%, #f83838 100%);
|
||||
margin: 0;
|
||||
padding: 8px 24px;
|
||||
margin-bottom: -11px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.zap-alert > div:nth-of-type(2) {
|
||||
.zap-alert > .zap-alert-header {
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 17px;
|
||||
background: #2d2d2d;
|
||||
padding: 27px 90px;
|
||||
padding: 12px 90px 24px 90px;
|
||||
font-size: 29px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zap-alert .highlight {
|
||||
color: #ff4468;
|
||||
.zap-alert-body {
|
||||
padding: 0 24px 24px 24px;
|
||||
margin-top: -20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes cssAnimation {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
.zap-alert .highlight {
|
||||
color: #ff4468;
|
||||
}
|
||||
|
||||
.views {
|
||||
|
@ -1,14 +1,149 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import "./widgets.css";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { NostrPrefix, createNostrLink } from "@snort/system";
|
||||
import Copy from "element/copy";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { getVoices, speak, toTextToSpeechParams } from "text2speech";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { eventToLink, hexToBech32 } from "utils";
|
||||
import { ZapAlertItem } from "./widgets/zaps";
|
||||
import { TopZappersWidget } from "./widgets/top-zappers";
|
||||
import { Views } from "./widgets/views";
|
||||
import groupBy from "lodash/groupBy";
|
||||
|
||||
interface ZapAlertConfigurationProps {
|
||||
npub: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
function ZapAlertConfiguration({ npub, baseUrl }: ZapAlertConfigurationProps) {
|
||||
const login = useLogin();
|
||||
const { formatMessage, formatDisplayName } = useIntl();
|
||||
|
||||
const [testText, setTestText] = useState("");
|
||||
const [textToSpeech, setTextToSpeech] = useState<boolean>(false);
|
||||
const [voice, setVoice] = useState<string | null>(null);
|
||||
const [minSatsForTextToSpeech, setMinSatsForTextToSpeech] = useState<string>("21");
|
||||
|
||||
// Google propietary voices are not available on OBS browser
|
||||
const voices = getVoices().filter(v => !v.name.includes("Google"));
|
||||
const groupedVoices = useMemo(() => {
|
||||
return groupBy(voices, v => v.lang);
|
||||
}, [voices]);
|
||||
const languages = useMemo(() => {
|
||||
return Object.keys(groupedVoices).sort();
|
||||
}, [groupedVoices]);
|
||||
const selectedVoice = useMemo(() => {
|
||||
return voices.find(v => v.voiceURI === voice);
|
||||
}, [voice]);
|
||||
|
||||
const isTextToSpeechSupported = "speechSynthesis" in window;
|
||||
const isTextToSpeechEnabled = voices.length > 0 && textToSpeech;
|
||||
|
||||
const query = useMemo(() => {
|
||||
const params = toTextToSpeechParams({
|
||||
voiceURI: voice,
|
||||
minSats: voice ? Number(minSatsForTextToSpeech) : null,
|
||||
});
|
||||
const queryParams = params.toString();
|
||||
return queryParams.length > 0 ? `?${queryParams}` : "";
|
||||
}, [voice, minSatsForTextToSpeech]);
|
||||
|
||||
function testVoice() {
|
||||
if (selectedVoice) {
|
||||
speak(selectedVoice, testText);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Zap Alert" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/alert/${npub}/zaps${query}`} />
|
||||
<ZapAlertItem
|
||||
item={{
|
||||
id: "",
|
||||
valid: true,
|
||||
content: testText,
|
||||
zapService: "",
|
||||
anonZap: false,
|
||||
errors: [],
|
||||
sender: login?.pubkey,
|
||||
amount: 1_000_000,
|
||||
}}
|
||||
/>
|
||||
<div className="text-to-speech-settings">
|
||||
<div
|
||||
className="paper"
|
||||
onClick={() => setTextToSpeech(!textToSpeech)}
|
||||
style={{ cursor: isTextToSpeechSupported ? "pointer" : "not-allowed" }}>
|
||||
<input
|
||||
disabled={!isTextToSpeechSupported}
|
||||
type="checkbox"
|
||||
checked={textToSpeech}
|
||||
onChange={ev => setTextToSpeech(ev.target.checked)}
|
||||
/>
|
||||
<FormattedMessage defaultMessage="Enable text to speech" />
|
||||
</div>
|
||||
{isTextToSpeechEnabled && (
|
||||
<>
|
||||
<div className="paper labeled-input">
|
||||
<label htmlFor="minimum-sats">
|
||||
<FormattedMessage defaultMessage="Minimum amount for text to speech" />
|
||||
</label>
|
||||
<input
|
||||
id="minimum-sats"
|
||||
type="number"
|
||||
min="1"
|
||||
value={minSatsForTextToSpeech}
|
||||
onChange={ev => setMinSatsForTextToSpeech(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="paper labeled-input">
|
||||
<label htmlFor="voice-selector">
|
||||
<FormattedMessage defaultMessage="Voice" />
|
||||
</label>
|
||||
<select id="voice-selector" onChange={ev => setVoice(ev.target.value)}>
|
||||
<option value="">
|
||||
<FormattedMessage defaultMessage="Select voice..." />
|
||||
</option>
|
||||
{languages.map(l => (
|
||||
<optgroup label={formatDisplayName(l, { type: "language" })}>
|
||||
{groupedVoices[l].map(v => (
|
||||
<option value={v.voiceURI}>{v.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{voice && (
|
||||
<>
|
||||
<div className="paper labeled-input">
|
||||
<label htmlFor="zap-alert-text">
|
||||
<FormattedMessage defaultMessage="Zap message" />
|
||||
</label>
|
||||
<textarea
|
||||
id="zap-alert-text"
|
||||
placeholder={formatMessage({ defaultMessage: "Insert text to speak" })}
|
||||
value={testText}
|
||||
onChange={ev => setTestText(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button disabled={testText.length === 0} className="btn" onClick={testVoice}>
|
||||
<FormattedMessage defaultMessage="Test voice" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetsPage() {
|
||||
const login = useLogin();
|
||||
@ -18,6 +153,7 @@ export function WidgetsPage() {
|
||||
const npub = hexToBech32("npub", login?.pubkey);
|
||||
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
return (
|
||||
<div className="widgets g8">
|
||||
<div className="flex f-col g8">
|
||||
@ -27,21 +163,7 @@ export function WidgetsPage() {
|
||||
<Copy text={`${baseUrl}/chat/${npub}`} />
|
||||
</div>
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Zap Alert" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/alert/${npub}/zaps`} />
|
||||
<ZapAlertItem
|
||||
item={{
|
||||
id: "",
|
||||
valid: true,
|
||||
zapService: "",
|
||||
anonZap: false,
|
||||
errors: [],
|
||||
sender: login?.pubkey,
|
||||
amount: 1_000_000,
|
||||
}}
|
||||
/>
|
||||
<ZapAlertConfiguration npub={npub} baseUrl={baseUrl} />
|
||||
</div>
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
|
@ -7,9 +7,14 @@ export function Views({ link }: { link: NostrLink }) {
|
||||
const current = useCurrentStreamFeed(link, true);
|
||||
|
||||
const viewers = findTag(current, "current_participants");
|
||||
const n = Number(viewers);
|
||||
return (
|
||||
<div className="views">
|
||||
<FormattedMessage defaultMessage="{n} viewers" values={{ n: Number(viewers) }} />
|
||||
{isNaN(n) ? (
|
||||
<FormattedMessage defaultMessage="No viewer data available" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="{n} viewers" values={{ n }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,47 +1,102 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { NostrLink, ParsedZap } from "@snort/system";
|
||||
import type { NostrLink, ParsedZap } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useZaps } from "hooks/zaps";
|
||||
import { useMutedPubkeys } from "hooks/lists";
|
||||
import { formatSats } from "number";
|
||||
import { useTextToSpeechParams, getVoices, speak } from "text2speech";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { eventToLink } from "utils";
|
||||
import { getHost, eventToLink } from "utils";
|
||||
|
||||
function useZapQueue(zapStream: ParsedZap[], zapTime = 10_000) {
|
||||
const zaps = useMemo(() => {
|
||||
return zapStream.reverse();
|
||||
}, [zapStream]);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(0);
|
||||
const zap = useMemo(() => {
|
||||
return zaps.at(currentIndex);
|
||||
}, [zaps, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zap) {
|
||||
setTimeout(() => {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}, zapTime);
|
||||
}
|
||||
}, [zap]);
|
||||
|
||||
return zap;
|
||||
}
|
||||
|
||||
export function ZapAlerts({ link }: { link: NostrLink }) {
|
||||
const currentEvent = useCurrentStreamFeed(link, true);
|
||||
const currentLink = currentEvent ? eventToLink(currentEvent) : undefined;
|
||||
const host = getHost(currentEvent);
|
||||
const zaps = useZaps(currentLink, true);
|
||||
const zap = useZapQueue(zaps);
|
||||
const mutedPubkeys = useMutedPubkeys(host, true);
|
||||
const { voiceURI, minSats } = useTextToSpeechParams();
|
||||
const voices = getVoices();
|
||||
const voice = useMemo(() => {
|
||||
return voices.find(v => v.voiceURI === voiceURI);
|
||||
}, [voices, voiceURI]);
|
||||
|
||||
return (
|
||||
<div className="flex f-center f-col zap-alert-widgets">
|
||||
{zaps.slice(0, 5).map(v => (
|
||||
<ZapAlertItem key={v.id} item={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!zap) return;
|
||||
|
||||
if (mutedPubkeys.has(zap?.sender ?? "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = zap.content ?? "";
|
||||
if (text.length > 0 && voice) {
|
||||
try {
|
||||
if (zap.amount >= minSats) {
|
||||
speak(voice, text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}, [zap?.id]);
|
||||
|
||||
return <div className="flex f-center f-col zap-alerts-widget">{zap && <ZapAlertItem key={zap.id} item={zap} />}</div>;
|
||||
}
|
||||
|
||||
export function ZapAlertItem({ item }: { item: ParsedZap }) {
|
||||
const profile = useUserProfile(item.sender);
|
||||
|
||||
if (!profile) return;
|
||||
|
||||
return (
|
||||
<div className="zap-alert">
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Incoming Zap" />
|
||||
<>
|
||||
<div className="zap-alert">
|
||||
<div className="zap-alert-title">
|
||||
<FormattedMessage defaultMessage="Incoming Zap" />
|
||||
</div>
|
||||
<div className="zap-alert-header">
|
||||
<FormattedMessage
|
||||
defaultMessage="{name} with {amount}"
|
||||
values={{
|
||||
name: (
|
||||
<span className="highlight">
|
||||
{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)}
|
||||
</span>
|
||||
),
|
||||
amount: <span className="highlight"> {formatSats(item.amount)}</span>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{item.content && item.content.length > 0 && (
|
||||
<p dir="auto" className="zap-alert-body">
|
||||
{item.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
defaultMessage="{name} with {amount}"
|
||||
values={{
|
||||
name: (
|
||||
<span className="highlight">
|
||||
{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)}
|
||||
</span>
|
||||
),
|
||||
amount: <span className="highlight"> {formatSats(item.amount)}</span>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
53
src/text2speech.ts
Normal file
53
src/text2speech.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function useQuery() {
|
||||
const { search } = useLocation();
|
||||
return useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
interface TextToSpeechConfig {
|
||||
voiceURI: string | null;
|
||||
minSats: number;
|
||||
}
|
||||
|
||||
export function useTextToSpeechParams(): TextToSpeechConfig {
|
||||
const q = useQuery();
|
||||
const voiceURI = q.get("voiceURI");
|
||||
const minSats = Number(q.get("minSats")) ?? 21;
|
||||
return { voiceURI, minSats };
|
||||
}
|
||||
|
||||
interface TextToSpeechConfigParams {
|
||||
voiceURI: string | null;
|
||||
minSats: number | null;
|
||||
}
|
||||
|
||||
export function toTextToSpeechParams(config: TextToSpeechConfigParams): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (config.voiceURI) {
|
||||
params.set("voiceURI", config.voiceURI);
|
||||
}
|
||||
if (config.minSats) {
|
||||
params.set("minSats", String(config.minSats));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function getVoices() {
|
||||
if ("speechSynthesis" in window) {
|
||||
return speechSynthesis.getVoices();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function speak(voice: SpeechSynthesisVoice, text: string) {
|
||||
try {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.voice = voice;
|
||||
utterance.rate = 0.8;
|
||||
speechSynthesis.speak(utterance);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
@ -23,11 +23,14 @@
|
||||
"5QYdPU": "Start Time",
|
||||
"5kx+2v": "Server Url",
|
||||
"6Z2pvJ": "Stream Providers",
|
||||
"6pr6hJ": "Minimum amount for text to speech",
|
||||
"8YT6ja": "Insert text to speak",
|
||||
"9WRlF4": "Send",
|
||||
"9a9+ww": "Title",
|
||||
"9anxhq": "Starts",
|
||||
"AIHaPH": "{person} zapped {amount} sats",
|
||||
"Atr2p4": "NSFW Content",
|
||||
"AukrPM": "No viewer data available",
|
||||
"AyGauy": "Login",
|
||||
"BGxpTN": "Stream Chat",
|
||||
"Bep/gA": "Private key",
|
||||
@ -82,6 +85,7 @@
|
||||
"cPIKU2": "Following",
|
||||
"cvAsEh": "Streamed on {date}",
|
||||
"cyR7Kh": "Back",
|
||||
"d5zWyh": "Test voice",
|
||||
"dVD/AR": "Top Zappers",
|
||||
"ebmhes": "Nostr Extension",
|
||||
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
|
||||
@ -89,6 +93,7 @@
|
||||
"fc2iho": "Add File",
|
||||
"feZ/kG": "Login with Private Key (insecure)",
|
||||
"hGQqkW": "Schedule",
|
||||
"heyxZL": "Enable text to speech",
|
||||
"hpl4BP": "Chat Widget",
|
||||
"ieGrWo": "Follow",
|
||||
"itPgxd": "Profile",
|
||||
@ -99,6 +104,7 @@
|
||||
"kp0NPF": "Planned",
|
||||
"lZpRMR": "Check here if this stream contains nudity or pornographic content.",
|
||||
"ljmS5P": "Endpoint",
|
||||
"mnJYBQ": "Voice",
|
||||
"mtNGwh": "A short description of the content",
|
||||
"nBCvvJ": "Topup",
|
||||
"nOaArs": "Setup Profile",
|
||||
@ -113,6 +119,7 @@
|
||||
"rgsbu9": "Current Viewers",
|
||||
"s5ksS7": "Image Link",
|
||||
"s7V+5p": "Confirm your age",
|
||||
"sInm1h": "Zap message",
|
||||
"tG1ST3": "Incoming Zap",
|
||||
"tM6fNW": "Amazing! Continue..",
|
||||
"thsiMl": "terms and conditions",
|
||||
@ -123,9 +130,10 @@
|
||||
"w0Xm2F": "Start typing",
|
||||
"wCIL7o": "Broadcast on Nostr",
|
||||
"wEQDC6": "Edit",
|
||||
"wMKVFz": "Select voice...",
|
||||
"wOy57k": "Add stream goal",
|
||||
"wzWWzV": "Top zappers",
|
||||
"x82IOl": "Mute",
|
||||
"yzKwBQ": "eg. nsec1xyz",
|
||||
"zVDHAu": "Zap Alert"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user