feat: autocomplete custom emoji

This commit is contained in:
Alejandro Gomez 2023-06-24 08:25:25 +02:00
parent becae9cfb4
commit 4eb30813ed
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
17 changed files with 528 additions and 211 deletions

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -7,7 +7,10 @@
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"hls.js": "^1.4.6",
"lodash": "^4.17.21",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -43,7 +46,9 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/lodash": "^4.14.195",
"@webbtc/webln-types": "^1.0.12",
"prettier": "^2.8.8",
"typescript": "^5.1.3"
}
}

6
src/element/emoji.css Normal file
View File

@ -0,0 +1,6 @@
.emoji {
width: 21px;
height: 21px;
display: inline-block;
margin-bottom: -5px;
}

33
src/element/emoji.tsx Normal file
View File

@ -0,0 +1,33 @@
import "./emoji.css";
import { useMemo } from "react";
export type EmojiProps = {
name: string;
url: string;
};
export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} src={url} className="emoji" />;
}
export type EmojiTag = ["emoji", string, string];
export function Emojify({
content,
emoji,
}: {
content: string;
emoji: EmojiTag[];
}) {
const emojified = useMemo(() => {
return content.split(/:(\w+):/g).map((i) => {
const t = emoji.find((t) => t[1] === i);
if (t) {
return <Emoji name={t[1]} url={t[2]} />;
} else {
return i;
}
});
}, [content, emoji]);
return <>{emojified}</>;
}

View File

@ -74,4 +74,4 @@
display: flex;
align-items: center;
gap: 8px;
}
}

View File

@ -1,99 +1,75 @@
import "./live-chat.css";
import { EventKind, NostrLink, TaggedRawEvent, EventPublisher, parseZap } from "@snort/system";
import { useState } from "react";
import {
EventKind,
NostrLink,
TaggedRawEvent,
EventPublisher,
parseZap,
} from "@snort/system";
import { useState, type KeyboardEvent, type ChangeEvent } from "react";
import useEmoji from "hooks/emoji";
import { System } from "index";
import { useLiveChatFeed } from "hooks/live-chat";
import AsyncButton from "./async-button";
import { Profile } from "./profile";
import { Icon } from "./icon";
import { Text } from "./text";
import { Textarea } from "./textarea";
import Spinner from "./spinner";
import { useLogin } from "hooks/login";
import { useUserProfile } from "@snort/system-react";
import { formatSats, formatShort } from "number";
import { formatSats } from "number";
export interface LiveChatOptions {
canWrite?: boolean,
showHeader?: boolean
canWrite?: boolean;
showHeader?: boolean;
}
export function LiveChat({ link, options }: { link: NostrLink, options?: LiveChatOptions }) {
const [chat, setChat] = useState("");
export function LiveChat({
link,
options,
}: {
link: NostrLink;
options?: LiveChatOptions;
}) {
const messages = useLiveChatFeed(link);
const login = useLogin();
async function sendChatMessage() {
const pub = await EventPublisher.nip7();
if (chat.length > 1) {
const reply = await pub?.generic(eb => {
return eb
.kind(1311 as EventKind)
.content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
.processContent();
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
setChat("");
}
}
function writeMessage() {
return <>
<div className="input">
<input
type="text"
autoFocus={false}
onChange={v => setChat(v.target.value)}
value={chat}
placeholder="Message"
onKeyDown={async e => {
if (e.code === "Enter") {
e.preventDefault();
await sendChatMessage();
}
}}
/>
<Icon name="message" size={15} />
</div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send
</AsyncButton>
</>
}
return (
<div className="live-chat">
{(options?.showHeader ?? true) && <div className="header">
Stream Chat
</div>}
{(options?.showHeader ?? true) && (
<div className="header">Stream Chat</div>
)}
<div className="messages">
{[...(messages.data ?? [])]
.sort((a, b) => b.created_at - a.created_at)
.map(a => {
.map((a) => {
switch (a.kind) {
case 1311: {
return <ChatMessage ev={a} link={link} key={a.id} />;
}
case EventKind.ZapReceipt: {
return <ChatZap ev={a} key={a.id} />
return <ChatZap ev={a} key={a.id} />;
}
}
return null;
})}
{messages.data === undefined && <Spinner />}
</div>
{(options?.canWrite ?? true) && <div className="write-message">
{login ? writeMessage() : <p>Please login to write messages!</p>}
</div>}
{(options?.canWrite ?? true) && (
<div className="write-message">
{login ? (
<WriteMessage link={link} />
) : (
<p>Please login to write messages!</p>
)}
</div>
)}
</div>
);
}
function ChatMessage({ ev, link }: { ev: TaggedRawEvent, link: NostrLink }) {
function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
return (
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
<Profile pubkey={ev.pubkey} />
@ -113,19 +89,82 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
<div className="pill">
<div className="zap">
<Icon name="zap" />
<Profile pubkey={parsed.anonZap ? "" : (parsed.sender ?? "")} options={{
showAvatar: !parsed.anonZap,
overrideName: parsed.anonZap ? "Anonymous" : undefined
}} />
zapped
&nbsp;
<Profile
pubkey={parsed.anonZap ? "" : parsed.sender ?? ""}
options={{
showAvatar: !parsed.anonZap,
overrideName: parsed.anonZap ? "Anonymous" : undefined,
}}
/>
zapped &nbsp;
{formatSats(parsed.amount)}
&nbsp;
sats
&nbsp; sats
</div>
{parsed.content && <p>
{parsed.content}
</p>}
{parsed.content && <p>{parsed.content}</p>}
</div>
);
}
}
function WriteMessage({ link }: { link: NostrLink }) {
const [chat, setChat] = useState("");
const login = useLogin();
const emojis = useEmoji(login!.pubkey);
const names = emojis.map((t) => t.at(1));
async function sendChatMessage() {
const pub = await EventPublisher.nip7();
if (chat.length > 1) {
let messageEmojis: string[][] = [];
for (const name of names) {
if (chat.includes(`:${name}:`)) {
const e = emojis.find((t) => t.at(1) === name);
messageEmojis.push(e as string[]);
}
}
const reply = await pub?.generic((eb) => {
eb.kind(1311 as EventKind)
.content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
.processContent();
for (const e of messageEmojis) {
eb.tag(e);
}
return eb;
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
setChat("");
}
}
async function onKeyDown(e: KeyboardEvent) {
if (e.code === "Enter") {
e.preventDefault();
await sendChatMessage();
}
}
async function onChange(e: ChangeEvent) {
// @ts-expect-error
setChat(e.target.value);
}
return (
<>
<div className="input">
<Textarea
emojis={emojis}
value={chat}
onKeyDown={onKeyDown}
onChange={onChange}
/>
<Icon name="message" size={15} />
</div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send
</AsyncButton>
</>
);
}

View File

@ -14,4 +14,5 @@
background: #A7A7A7;
border: unset;
outline: unset;
}
object-fit: cover;
}

View File

@ -5,28 +5,39 @@ import { hexToBech32 } from "@snort/shared";
import { System } from "index";
export interface ProfileOptions {
showName?: boolean
showAvatar?: boolean
suffix?: string
overrideName?: string
showName?: boolean;
showAvatar?: boolean;
suffix?: string;
overrideName?: string;
}
export function getName(pk: string, user?: UserMetadata) {
const shortPubkey = hexToBech32("npub", pk).slice(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
if ((user?.name?.length ?? 0) > 0) {
return user?.name;
}
return shortPubkey;
const shortPubkey = hexToBech32("npub", pk).slice(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
if ((user?.name?.length ?? 0) > 0) {
return user?.name;
}
return shortPubkey;
}
export function Profile({ pubkey, options }: { pubkey: string, options?: ProfileOptions }) {
const profile = useUserProfile(System, pubkey);
export function Profile({
pubkey,
options,
}: {
pubkey: string;
options?: ProfileOptions;
}) {
const profile = useUserProfile(System, pubkey);
return <div className="profile">
{(options?.showAvatar ?? true) && <img src={profile?.picture ?? ""} />}
{(options?.showName ?? true) && (options?.overrideName ?? getName(pubkey, profile))}
return (
<div className="profile">
{(options?.showAvatar ?? true) && (
<img alt={profile?.name || pubkey} src={profile?.picture ?? ""} />
)}
{(options?.showName ?? true) &&
(options?.overrideName ?? getName(pubkey, profile))}
</div>
}
);
}

View File

@ -44,7 +44,12 @@ export default function QrCode(props: QrCodeProps) {
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link]);
}, [props.data, props.link, props.width, props.height, props.avatar]);
return <div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>;
return (
<div
className={`qr${props.className ? ` ${props.className}` : ""}`}
ref={qrRef}
></div>
);
}

View File

@ -1,25 +1,14 @@
import "./text.css";
import { useMemo } from "react";
import { TaggedRawEvent } from "@snort/system";
type Emoji = [string, string];
function replaceEmoji(content: string, emoji: Emoji[]) {
return content.split(/:(\w+):/g).map((i) => {
const t = emoji.find((a) => a[0] === i);
if (t) {
return <img alt={t[0]} src={t[1]} className="custom-emoji" />;
} else {
return i;
}
});
}
import { type EmojiTag, Emojify } from "./emoji";
export function Text({ ev }: { ev: TaggedRawEvent }) {
const emojis = useMemo(() => {
return ev.tags
.filter((t) => t.at(0) === "emoji")
.map((t) => t.slice(1) as Emoji);
return ev.tags.filter((t) => t.at(0) === "emoji").map((t) => t as EmojiTag);
}, [ev]);
return <span>{replaceEmoji(ev.content, emojis)}</span>;
return (
<span>
<Emojify content={ev.content} emoji={emojis} />
</span>
);
}

29
src/element/textarea.css Normal file
View File

@ -0,0 +1,29 @@
.rta__list {
border: none;
}
.rta__item:not(:last-child) {
border: none;
}
.rta__entity--selected .emoji-item {
text-decoration: none;
background: #F838D9;
}
.emoji-item {
color: white;
background: #171717;
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
padding: 10px;
}
.emoji-item:hover {
color: #171717;
background: white;
}
.emoji-item .emoji-image {
margin: 0 8px;
}

63
src/element/textarea.tsx Normal file
View File

@ -0,0 +1,63 @@
import "./textarea.css";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import type { KeyboardEvent, ChangeEvent } from "react";
import { Emoji, type EmojiTag } from "./emoji";
import uniqWith from "lodash/uniqWith";
import isEqual from "lodash/isEqual";
interface EmojiItemProps {
name: string;
url: string;
}
const EmojiItem = ({ entity: { name, url } }: { entity: EmojiItemProps }) => {
return (
<div className="emoji-item">
<div className="emoji-image">
<Emoji name={name} url={url} />
</div>
<div className="emoji-name">{name}</div>
</div>
);
};
interface TextareaProps {
emojis: EmojiTag[];
value: string;
onChange: (e: ChangeEvent<Element>) => void;
onKeyDown: (e: KeyboardEvent<Element>) => void;
}
export function Textarea({ emojis, ...props }: TextareaProps) {
const emojiDataProvider = async (token: string) => {
const results = emojis
.map((t) => {
return {
name: t.at(1) || "",
url: t.at(2) || "",
};
})
.filter(({ name }) => name.toLowerCase().includes(token.toLowerCase()));
return uniqWith(results, isEqual).slice(0, 5);
};
const trigger = {
":": {
dataProvider: emojiDataProvider,
component: EmojiItem,
output: (item: EmojiItemProps) => `:${item.name}:`,
},
};
return (
<ReactTextareaAutocomplete
dir="auto"
loadingComponent={() => <span>Loading...</span>}
placeholder="Message"
autoFocus={false}
// @ts-expect-error
trigger={trigger}
{...props}
/>
);
}

70
src/hooks/emoji.tsx Normal file
View File

@ -0,0 +1,70 @@
import { RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { useMemo } from "react";
import type { EmojiTag } from "../element/emoji";
export default function useEmoji(pubkey: string) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter()
.authors([pubkey])
.kinds([10030 as EventKind, 30030 as EventKind]);
return rb;
}, [pubkey]);
const { data } = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
const userEmoji = data ?? [];
const related = useMemo(() => {
if (userEmoji) {
const tags = userEmoji.at(0)?.tags ?? [];
return tags.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
);
}
return [];
}, [userEmoji]);
const subRelated = useMemo(() => {
const splitted = related.map((t) => t.at(1)!.split(":"));
const authors = splitted
.map((s) => s.at(1))
.filter((s) => s)
.map((s) => s as string);
const identifiers = splitted
.map((s) => s.at(2))
.filter((s) => s)
.map((s) => s as string);
const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter()
.kinds([30030 as EventKind])
.authors(authors)
// @ts-expect-error
.tag(["d", identifiers]);
return rb;
}, [related]);
const { data: relatedData } = useRequestBuilder<FlatNoteStore>(
System,
FlatNoteStore,
subRelated
);
const emojiPacks = relatedData ?? [];
const emojis = useMemo(() => {
return userEmoji
.concat(emojiPacks)
.map((ev) => {
return ev.tags.filter((t) => t.at(0) === "emoji");
})
.flat() as EmojiTag[];
}, [userEmoji, emojiPacks]);
return emojis;
}

View File

@ -1,56 +1,56 @@
import './index.css';
import "./index.css";
import React from 'react';
import ReactDOM from 'react-dom/client';
import React from "react";
import ReactDOM from "react-dom/client";
import { NostrSystem } from "@snort/system";
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RootPage } from './pages/root';
import { LayoutPage } from 'pages/layout';
import { StreamPage } from 'pages/stream-page';
import { ChatPopout } from 'pages/chat-popout';
import { LoginStore } from 'login';
import { RootPage } from "./pages/root";
import { LayoutPage } from "pages/layout";
import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login";
export const System = new NostrSystem({
});
export const System = new NostrSystem({});
export const Login = new LoginStore();
export const Relays = [
"wss://relay.snort.social",
"wss://nos.lol",
"wss://relay.damus.io",
"wss://nostr.wine"
"wss://nostr.wine",
];
Relays.forEach(r => System.ConnectToRelay(r, { read: true, write: true }));
Relays.forEach((r) => System.ConnectToRelay(r, { read: true, write: true }));
const router = createBrowserRouter([
{
element: <LayoutPage />,
loader: async() => {
loader: async () => {
await System.Init();
return null;
},
children: [
{
path: "/",
element: <RootPage />
element: <RootPage />,
},
{
path: "/live/:id",
element: <StreamPage />
}
]
element: <StreamPage />,
},
],
},
{
path: "/chat/:id",
element: <ChatPopout />
}
])
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement);
element: <ChatPopout />,
},
]);
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLDivElement
);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
);

View File

@ -11,59 +11,72 @@ import { NewStream } from "element/new-stream";
import { useState } from "react";
export function LayoutPage() {
const navigate = useNavigate();
const login = useLogin();
const [newStream, setNewStream] = useState(false);
const navigate = useNavigate();
const login = useLogin();
const [newStream, setNewStream] = useState(false);
async function doLogin() {
const pub = await EventPublisher.nip7();
if (pub) {
Login.loginWithPubkey(pub.pubKey);
}
async function doLogin() {
const pub = await EventPublisher.nip7();
if (pub) {
Login.loginWithPubkey(pub.pubKey);
}
}
function loggedIn() {
if (!login) return;
function loggedIn() {
if (!login) return;
return <>
<button type="button" className="btn btn-primary" onClick={() => setNewStream(true)}>
New Stream
<Icon name="signal" />
</button>
<Profile pubkey={login.pubkey} options={{
showName: false
}} />
</>
}
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => setNewStream(true)}
>
New Stream
<Icon name="signal" />
</button>
<Profile
pubkey={login.pubkey}
options={{
showName: false,
}}
/>
</>
);
}
function loggedOut() {
if (login) return;
function loggedOut() {
if (login) return;
return <>
<AsyncButton type="button" className="btn btn-border" onClick={doLogin}>
Login
<Icon name="login" />
</AsyncButton>
</>
}
return (
<>
<AsyncButton type="button" className="btn btn-border" onClick={doLogin}>
Login
<Icon name="login" />
</AsyncButton>
</>
);
}
return <>
<header>
<div onClick={() => navigate("/")}>
S
</div>
<div className="input">
<input type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>
<div>
{loggedIn()}
{loggedOut()}
</div>
</header>
<Outlet />
{newStream && <Modal onClose={() => setNewStream(false)} >
<NewStream onFinish={() => navigate("/")} />
</Modal>}
return (
<>
<header>
<div onClick={() => navigate("/")}>S</div>
<div className="input">
<input type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>
<div>
{loggedIn()}
{loggedOut()}
</div>
</header>
<Outlet />
{newStream && (
<Modal onClose={() => setNewStream(false)}>
<NewStream onFinish={() => navigate("/")} />
</Modal>
)}
</>
}
);
}

View File

@ -53,33 +53,42 @@ export function StreamPage() {
<h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p>
<div className="tags">
<span className={`pill${isLive ? " live" : ""}`}>
{status}
</span>
<span className={`pill${isLive ? " live" : ""}`}>{status}</span>
{thisEvent.data?.tags
.filter(a => a[0] === "t")
.map(a => a[1])
.map(a => (
.filter((a) => a[0] === "t")
.map((a) => a[1])
.map((a) => (
<span className="pill" key={a}>
{a}
</span>
))}
</div>
{isMine && <div className="actions">
<button type="button" className="btn" onClick={() => setEdit(true)}>
Edit
</button>
<AsyncButton type="button" className="btn btn-red" onClick={deleteStream}>
Delete
</AsyncButton>
</div>}
{isMine && (
<div className="actions">
<button
type="button"
className="btn"
onClick={() => setEdit(true)}
>
Edit
</button>
<AsyncButton
type="button"
className="btn btn-red"
onClick={deleteStream}
>
Delete
</AsyncButton>
</div>
)}
</div>
<div>
<div className="flex g24">
<Profile
pubkey={thisEvent.data?.pubkey ?? ""}
/>
<button onClick={() => setZap(true)} className="btn btn-primary zap">
<Profile pubkey={thisEvent.data?.pubkey ?? ""} />
<button
onClick={() => setZap(true)}
className="btn btn-primary zap"
>
Zap
<Icon name="zap" size={16} />
</button>
@ -88,16 +97,24 @@ export function StreamPage() {
</div>
</div>
<LiveChat link={link} />
{zap && zapTarget && thisEvent.data && <Modal onClose={() => setZap(false)}>
<SendZaps
lnurl={zapTarget}
ev={thisEvent.data}
targetName={getName(thisEvent.data.pubkey, profile)}
onFinish={() => setZap(false)} />
</Modal>}
{edit && thisEvent.data && <Modal onClose={() => setEdit(false)}>
<NewStream ev={thisEvent.data} onFinish={() => window.location.reload()} />
</Modal>}
{zap && zapTarget && thisEvent.data && (
<Modal onClose={() => setZap(false)}>
<SendZaps
lnurl={zapTarget}
ev={thisEvent.data}
targetName={getName(thisEvent.data.pubkey, profile)}
onFinish={() => setZap(false)}
/>
</Modal>
)}
{edit && thisEvent.data && (
<Modal onClose={() => setEdit(false)}>
<NewStream
ev={thisEvent.data}
onFinish={() => window.location.reload()}
/>
</Modal>
)}
</div>
);
}

View File

@ -2188,6 +2188,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash@^4.14.195":
version "4.14.195"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
integrity sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==
"@types/mime@*":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
@ -2318,6 +2323,13 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
"@types/webscopeio__react-textarea-autocomplete@^4.7.2":
version "4.7.2"
resolved "https://registry.yarnpkg.com/@types/webscopeio__react-textarea-autocomplete/-/webscopeio__react-textarea-autocomplete-4.7.2.tgz#605e8a6b4194fb4b6e55df8a19bc8fcd56319cfa"
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
dependencies:
"@types/react" "*"
"@types/ws@^8.5.5":
version "8.5.5"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb"
@ -2561,6 +2573,14 @@
resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-1.0.12.tgz#ddb5f0dbaa0a853ef21a4f36a603199d43cc8682"
integrity sha512-uCsJt78RaW/UYDXwAjjs6aj7fiXyozwMknWvPROCaGMx+rXoPddtDjMIMbMFLvUJVQmnyzpqGkx/0jBIvVaVvA==
"@webscopeio/react-textarea-autocomplete@^4.9.2":
version "4.9.2"
resolved "https://registry.yarnpkg.com/@webscopeio/react-textarea-autocomplete/-/react-textarea-autocomplete-4.9.2.tgz#b39e57d8048ad2e8790d70073afe63eafa877345"
integrity sha512-9l5lbyA709d5HHvI/COflSnblBJeYGxB2/0ghP3m3YViLzXRMzJwaXqnqz6oA96y7QdR3pQWYtVmkUKA0AUVAA==
dependencies:
custom-event "^1.0.1"
textarea-caret "3.0.2"
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -3711,6 +3731,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
custom-event@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -7550,6 +7575,11 @@ prelude-ls@~1.1.2:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
prettier@^2.8.8:
version "2.8.8"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
pretty-bytes@^5.3.0, pretty-bytes@^5.4.1:
version "5.6.0"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
@ -8787,6 +8817,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
textarea-caret@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.0.2.tgz#f360c48699aa1abf718680a43a31a850665c2caf"
integrity sha512-gRzeti2YS4did7UJnPQ47wrjD+vp+CJIe9zbsu0bJ987d8QVLvLNG9757rqiQTIy4hGIeFauTTJt5Xkn51UkXg==
thenify-all@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"