Merge pull request 'feat: text to speech and animation for zap alerts' (#96) from zap-alerts into main

Reviewed-on: Kieran/stream#96
Reviewed-by: Kieran <kieran@noreply.localhost>
This commit is contained in:
2023-09-11 09:10:13 +00:00
39 changed files with 390 additions and 94 deletions

View File

@ -71,6 +71,12 @@
"6Z2pvJ": { "6Z2pvJ": {
"defaultMessage": "Stream Providers" "defaultMessage": "Stream Providers"
}, },
"6pr6hJ": {
"defaultMessage": "Minimum amount for text to speech"
},
"8YT6ja": {
"defaultMessage": "Insert text to speak"
},
"9WRlF4": { "9WRlF4": {
"defaultMessage": "Send" "defaultMessage": "Send"
}, },
@ -86,6 +92,9 @@
"Atr2p4": { "Atr2p4": {
"defaultMessage": "NSFW Content" "defaultMessage": "NSFW Content"
}, },
"AukrPM": {
"defaultMessage": "No viewer data available"
},
"AyGauy": { "AyGauy": {
"defaultMessage": "Login" "defaultMessage": "Login"
}, },
@ -248,6 +257,9 @@
"cyR7Kh": { "cyR7Kh": {
"defaultMessage": "Back" "defaultMessage": "Back"
}, },
"d5zWyh": {
"defaultMessage": "Test voice"
},
"dVD/AR": { "dVD/AR": {
"defaultMessage": "Top Zappers" "defaultMessage": "Top Zappers"
}, },
@ -269,6 +281,9 @@
"hGQqkW": { "hGQqkW": {
"defaultMessage": "Schedule" "defaultMessage": "Schedule"
}, },
"heyxZL": {
"defaultMessage": "Enable text to speech"
},
"hpl4BP": { "hpl4BP": {
"defaultMessage": "Chat Widget" "defaultMessage": "Chat Widget"
}, },
@ -299,6 +314,9 @@
"ljmS5P": { "ljmS5P": {
"defaultMessage": "Endpoint" "defaultMessage": "Endpoint"
}, },
"mnJYBQ": {
"defaultMessage": "Voice"
},
"mtNGwh": { "mtNGwh": {
"defaultMessage": "A short description of the content" "defaultMessage": "A short description of the content"
}, },
@ -341,6 +359,9 @@
"s7V+5p": { "s7V+5p": {
"defaultMessage": "Confirm your age" "defaultMessage": "Confirm your age"
}, },
"sInm1h": {
"defaultMessage": "Zap message"
},
"tG1ST3": { "tG1ST3": {
"defaultMessage": "Incoming Zap" "defaultMessage": "Incoming Zap"
}, },
@ -371,6 +392,9 @@
"wEQDC6": { "wEQDC6": {
"defaultMessage": "Edit" "defaultMessage": "Edit"
}, },
"wMKVFz": {
"defaultMessage": "Select voice..."
},
"wOy57k": { "wOy57k": {
"defaultMessage": "Add stream goal" "defaultMessage": "Add stream goal"
}, },
@ -380,6 +404,9 @@
"x82IOl": { "x82IOl": {
"defaultMessage": "Mute" "defaultMessage": "Mute"
}, },
"y867Vs": {
"defaultMessage": "Volume"
},
"yzKwBQ": { "yzKwBQ": {
"defaultMessage": "eg. nsec1xyz" "defaultMessage": "eg. nsec1xyz"
}, },

View File

@ -1,49 +1,83 @@
.zap-alert-widgets .zap-alert { .zap-alerts-widget .zap-alert {
animation: cssAnimation 0s ease-in 10s forwards; animation: fadeInOut 10s;
animation-fill-mode: forwards; }
@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 { .zap-alert {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
align-items: center; 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; width: fit-content;
text-align: center;
left: 120px;
margin-top: -20px;
font-size: 21px; font-size: 21px;
font-weight: 600; font-weight: 600;
border-radius: 33px; border-radius: 33px;
background: linear-gradient(135deg, #882bff 0%, #f83838 100%); background: linear-gradient(135deg, #882bff 0%, #f83838 100%);
margin: 0;
padding: 8px 24px; padding: 8px 24px;
margin-bottom: -11px;
z-index: 2; z-index: 2;
} }
.zap-alert > div:nth-of-type(2) { .zap-alert > .zap-alert-header {
z-index: 1; z-index: 1;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 17px; padding: 12px 90px 24px 90px;
background: #2d2d2d;
padding: 27px 90px;
font-size: 29px; font-size: 29px;
font-weight: 600; font-weight: 600;
} }
.zap-alert .highlight { .zap-alert-body {
color: #ff4468; padding: 0 24px 24px 24px;
margin-top: -20px;
margin-bottom: 0;
} }
@keyframes cssAnimation { .zap-alert .highlight {
from { color: #ff4468;
opacity: 1;
}
to {
opacity: 0;
}
} }
.views { .views {

View File

@ -1,14 +1,165 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import "./widgets.css"; import "./widgets.css";
import { useState, useMemo } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { NostrPrefix, createNostrLink } from "@snort/system"; import { NostrPrefix, createNostrLink } from "@snort/system";
import Copy from "element/copy"; import Copy from "element/copy";
import { useCurrentStreamFeed } from "hooks/current-stream-feed"; import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { getVoices, speak, toTextToSpeechParams } from "text2speech";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { FormattedMessage } from "react-intl";
import { eventToLink, hexToBech32 } from "utils"; import { eventToLink, hexToBech32 } from "utils";
import { ZapAlertItem } from "./widgets/zaps"; import { ZapAlertItem } from "./widgets/zaps";
import { TopZappersWidget } from "./widgets/top-zappers"; import { TopZappersWidget } from "./widgets/top-zappers";
import { Views } from "./widgets/views"; 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");
const [volume, setVolume] = useState<number>(1);
// 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,
volume,
});
const queryParams = params.toString();
return queryParams.length > 0 ? `?${queryParams}` : "";
}, [voice, volume, minSatsForTextToSpeech]);
function testVoice() {
if (selectedVoice) {
speak(selectedVoice, testText, volume);
}
}
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="volume">
<FormattedMessage defaultMessage="Volume" />
</label>
<input
id="volume"
type="number"
min="0"
max="1"
step="0.1"
value={volume}
onChange={ev => setVolume(Number(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() { export function WidgetsPage() {
const login = useLogin(); const login = useLogin();
@ -18,6 +169,7 @@ export function WidgetsPage() {
const npub = hexToBech32("npub", login?.pubkey); const npub = hexToBech32("npub", login?.pubkey);
const baseUrl = `${window.location.protocol}//${window.location.host}`; const baseUrl = `${window.location.protocol}//${window.location.host}`;
return ( return (
<div className="widgets g8"> <div className="widgets g8">
<div className="flex f-col g8"> <div className="flex f-col g8">
@ -27,21 +179,7 @@ export function WidgetsPage() {
<Copy text={`${baseUrl}/chat/${npub}`} /> <Copy text={`${baseUrl}/chat/${npub}`} />
</div> </div>
<div className="flex f-col g8"> <div className="flex f-col g8">
<h3> <ZapAlertConfiguration npub={npub} baseUrl={baseUrl} />
<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,
}}
/>
</div> </div>
<div className="flex f-col g8"> <div className="flex f-col g8">
<h3> <h3>

View File

@ -7,9 +7,14 @@ export function Views({ link }: { link: NostrLink }) {
const current = useCurrentStreamFeed(link, true); const current = useCurrentStreamFeed(link, true);
const viewers = findTag(current, "current_participants"); const viewers = findTag(current, "current_participants");
const n = Number(viewers);
return ( return (
<div className="views"> <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> </div>
); );
} }

View File

@ -1,47 +1,102 @@
import { useMemo, useState, useEffect } from "react";
import { hexToBech32 } from "@snort/shared"; 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 { useUserProfile } from "@snort/system-react";
import { useCurrentStreamFeed } from "hooks/current-stream-feed"; import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { useZaps } from "hooks/zaps"; import { useZaps } from "hooks/zaps";
import { useMutedPubkeys } from "hooks/lists";
import { formatSats } from "number"; import { formatSats } from "number";
import { useTextToSpeechParams, getVoices, speak } from "text2speech";
import { FormattedMessage } from "react-intl"; 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 }) { export function ZapAlerts({ link }: { link: NostrLink }) {
const currentEvent = useCurrentStreamFeed(link, true); const currentEvent = useCurrentStreamFeed(link, true);
const currentLink = currentEvent ? eventToLink(currentEvent) : undefined; const currentLink = currentEvent ? eventToLink(currentEvent) : undefined;
const host = getHost(currentEvent);
const zaps = useZaps(currentLink, true); const zaps = useZaps(currentLink, true);
const zap = useZapQueue(zaps);
const mutedPubkeys = useMutedPubkeys(host, true);
const { voiceURI, minSats, volume } = useTextToSpeechParams();
const voices = getVoices();
const voice = useMemo(() => {
return voices.find(v => v.voiceURI === voiceURI);
}, [voices, voiceURI]);
return ( useEffect(() => {
<div className="flex f-center f-col zap-alert-widgets"> if (!zap) return;
{zaps.slice(0, 5).map(v => (
<ZapAlertItem key={v.id} item={v} /> if (mutedPubkeys.has(zap?.sender ?? "")) {
))} return;
</div> }
);
const text = zap.content ?? "";
if (text.length > 0 && voice) {
try {
if (zap.amount >= minSats) {
speak(voice, text, volume);
}
} 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 }) { export function ZapAlertItem({ item }: { item: ParsedZap }) {
const profile = useUserProfile(item.sender); const profile = useUserProfile(item.sender);
if (!profile) return; if (!profile) return;
return ( return (
<div className="zap-alert"> <>
<div> <div className="zap-alert">
<FormattedMessage defaultMessage="Incoming Zap" /> <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)}&nbsp;
</span>
),
amount: <span className="highlight">&nbsp;{formatSats(item.amount)}</span>,
}}
/>
</div>
{item.content && item.content.length > 0 && (
<p dir="auto" className="zap-alert-body">
{item.content}
</p>
)}
</div> </div>
<div> </>
<FormattedMessage
defaultMessage="{name} with {amount}"
values={{
name: (
<span className="highlight">
{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)}&nbsp;
</span>
),
amount: <span className="highlight">&nbsp;{formatSats(item.amount)}</span>,
}}
/>
</div>
</div>
); );
} }

60
src/text2speech.ts Normal file
View File

@ -0,0 +1,60 @@
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;
volume: number;
}
export function useTextToSpeechParams(): TextToSpeechConfig {
const q = useQuery();
const voiceURI = q.get("voiceURI");
const minSats = Number(q.get("minSats")) ?? 21;
const volume = Number(q.get("volume")) ?? 1;
return { voiceURI, minSats, volume };
}
interface TextToSpeechConfigParams {
voiceURI: string | null;
minSats: number | null;
volume: 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));
}
if (config.volume) {
params.set("volume", String(config.volume));
}
return params;
}
export function getVoices() {
if ("speechSynthesis" in window) {
return speechSynthesis.getVoices();
}
return [];
}
export function speak(voice: SpeechSynthesisVoice, text: string, volume: number) {
try {
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = voice;
utterance.volume = volume;
utterance.rate = 0.8;
speechSynthesis.speak(utterance);
} catch (e) {
console.error(e);
}
}

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -23,11 +23,14 @@
"5QYdPU": "Start Time", "5QYdPU": "Start Time",
"5kx+2v": "Server Url", "5kx+2v": "Server Url",
"6Z2pvJ": "Stream Providers", "6Z2pvJ": "Stream Providers",
"6pr6hJ": "Minimum amount for text to speech",
"8YT6ja": "Insert text to speak",
"9WRlF4": "Send", "9WRlF4": "Send",
"9a9+ww": "Title", "9a9+ww": "Title",
"9anxhq": "Starts", "9anxhq": "Starts",
"AIHaPH": "{person} zapped {amount} sats", "AIHaPH": "{person} zapped {amount} sats",
"Atr2p4": "NSFW Content", "Atr2p4": "NSFW Content",
"AukrPM": "No viewer data available",
"AyGauy": "Login", "AyGauy": "Login",
"BGxpTN": "Stream Chat", "BGxpTN": "Stream Chat",
"Bep/gA": "Private key", "Bep/gA": "Private key",
@ -82,6 +85,7 @@
"cPIKU2": "Following", "cPIKU2": "Following",
"cvAsEh": "Streamed on {date}", "cvAsEh": "Streamed on {date}",
"cyR7Kh": "Back", "cyR7Kh": "Back",
"d5zWyh": "Test voice",
"dVD/AR": "Top Zappers", "dVD/AR": "Top Zappers",
"ebmhes": "Nostr Extension", "ebmhes": "Nostr Extension",
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜", "f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
@ -89,6 +93,7 @@
"fc2iho": "Add File", "fc2iho": "Add File",
"feZ/kG": "Login with Private Key (insecure)", "feZ/kG": "Login with Private Key (insecure)",
"hGQqkW": "Schedule", "hGQqkW": "Schedule",
"heyxZL": "Enable text to speech",
"hpl4BP": "Chat Widget", "hpl4BP": "Chat Widget",
"ieGrWo": "Follow", "ieGrWo": "Follow",
"itPgxd": "Profile", "itPgxd": "Profile",
@ -99,6 +104,7 @@
"kp0NPF": "Planned", "kp0NPF": "Planned",
"lZpRMR": "Check here if this stream contains nudity or pornographic content.", "lZpRMR": "Check here if this stream contains nudity or pornographic content.",
"ljmS5P": "Endpoint", "ljmS5P": "Endpoint",
"mnJYBQ": "Voice",
"mtNGwh": "A short description of the content", "mtNGwh": "A short description of the content",
"nBCvvJ": "Topup", "nBCvvJ": "Topup",
"nOaArs": "Setup Profile", "nOaArs": "Setup Profile",
@ -113,6 +119,7 @@
"rgsbu9": "Current Viewers", "rgsbu9": "Current Viewers",
"s5ksS7": "Image Link", "s5ksS7": "Image Link",
"s7V+5p": "Confirm your age", "s7V+5p": "Confirm your age",
"sInm1h": "Zap message",
"tG1ST3": "Incoming Zap", "tG1ST3": "Incoming Zap",
"tM6fNW": "Amazing! Continue..", "tM6fNW": "Amazing! Continue..",
"thsiMl": "terms and conditions", "thsiMl": "terms and conditions",
@ -123,9 +130,11 @@
"w0Xm2F": "Start typing", "w0Xm2F": "Start typing",
"wCIL7o": "Broadcast on Nostr", "wCIL7o": "Broadcast on Nostr",
"wEQDC6": "Edit", "wEQDC6": "Edit",
"wMKVFz": "Select voice...",
"wOy57k": "Add stream goal", "wOy57k": "Add stream goal",
"wzWWzV": "Top zappers", "wzWWzV": "Top zappers",
"x82IOl": "Mute", "x82IOl": "Mute",
"y867Vs": "Volume",
"yzKwBQ": "eg. nsec1xyz", "yzKwBQ": "eg. nsec1xyz",
"zVDHAu": "Zap Alert" "zVDHAu": "Zap Alert"
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Alerta de Zap" "defaultMessage": "Alerta de Zap"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "هشدار زپ" "defaultMessage": "هشدار زپ"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "ザップアラート" "defaultMessage": "ザップアラート"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Оповещение о запе" "defaultMessage": "Оповещение о запе"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Tahadhari ya Zap" "defaultMessage": "Tahadhari ya Zap"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "Zap Alert" "defaultMessage": "Zap Alert"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "打闪提示" "defaultMessage": "打闪提示"
} }
} }

View File

@ -387,4 +387,3 @@
"defaultMessage": "打閃提示" "defaultMessage": "打閃提示"
} }
} }