mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-24 22:52:09 -05:00
Add auto complete back.
This commit is contained in:
parent
56dda66c1c
commit
f724cfdf4f
7 changed files with 389 additions and 6 deletions
364
src/components/common/AutoComplete.tsx
Normal file
364
src/components/common/AutoComplete.tsx
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
import { StateUpdater, useContext, useState } from "preact/hooks";
|
||||||
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
import { emojiDictionary } from "../../assets/emojis";
|
||||||
|
import UserIcon from "./user/UserIcon";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { SYSTEM_USER_ID, User } from "revolt.js";
|
||||||
|
import Emoji from "./Emoji";
|
||||||
|
|
||||||
|
export type AutoCompleteState =
|
||||||
|
| { type: "none" }
|
||||||
|
| {
|
||||||
|
type: "emoji";
|
||||||
|
matches: string[];
|
||||||
|
selected: number;
|
||||||
|
within: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "user";
|
||||||
|
matches: User[];
|
||||||
|
selected: number;
|
||||||
|
within: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchClues = {
|
||||||
|
users?: { type: 'channel', id: string } | { type: 'all' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoCompleteProps = {
|
||||||
|
state: AutoCompleteState,
|
||||||
|
setState: StateUpdater<AutoCompleteState>,
|
||||||
|
|
||||||
|
onKeyUp: (ev: KeyboardEvent) => void,
|
||||||
|
onKeyDown: (ev: KeyboardEvent) => boolean,
|
||||||
|
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void,
|
||||||
|
onClick: JSX.MouseEventHandler<HTMLButtonElement>,
|
||||||
|
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>,
|
||||||
|
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoComplete(setValue: (v?: string) => void, searchClues?: SearchClues): AutoCompleteProps {
|
||||||
|
const [state, setState] = useState<AutoCompleteState>({ type: 'none' });
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
|
function findSearchString(
|
||||||
|
el: HTMLTextAreaElement
|
||||||
|
): ["emoji" | "user", string, number] | undefined {
|
||||||
|
if (el.selectionStart === el.selectionEnd) {
|
||||||
|
let cursor = el.selectionStart;
|
||||||
|
let content = el.value.slice(0, cursor);
|
||||||
|
|
||||||
|
let valid = /\w/;
|
||||||
|
|
||||||
|
let j = content.length - 1;
|
||||||
|
if (content[j] === '@') {
|
||||||
|
return [
|
||||||
|
"user",
|
||||||
|
"",
|
||||||
|
j
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j >= 0 && valid.test(content[j])) {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (j === -1) return;
|
||||||
|
let current = content[j];
|
||||||
|
|
||||||
|
if (current === ":" || current === "@") {
|
||||||
|
let search = content.slice(j + 1, content.length);
|
||||||
|
if (search.length > 0) {
|
||||||
|
return [
|
||||||
|
current === ":" ? "emoji" : "user",
|
||||||
|
search.toLowerCase(),
|
||||||
|
j + 1
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
|
||||||
|
const el = ev.currentTarget;
|
||||||
|
|
||||||
|
let result = findSearchString(el);
|
||||||
|
if (result) {
|
||||||
|
let [type, search] = result;
|
||||||
|
const regex = new RegExp(search, 'i');
|
||||||
|
|
||||||
|
if (type === "emoji") {
|
||||||
|
// ! FIXME: we should convert it to a Binary Search Tree and use that
|
||||||
|
let matches = Object.keys(emojiDictionary)
|
||||||
|
.filter((emoji: string) => emoji.match(regex))
|
||||||
|
.splice(0, 5);
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
let currentPosition =
|
||||||
|
state.type !== "none"
|
||||||
|
? state.selected
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
type: "emoji",
|
||||||
|
matches,
|
||||||
|
selected: Math.min(currentPosition, matches.length - 1),
|
||||||
|
within: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "user" && searchClues?.users) {
|
||||||
|
let users: User[] = [];
|
||||||
|
switch (searchClues.users.type) {
|
||||||
|
case 'all': users = client.users.toArray(); break;
|
||||||
|
case 'channel': {
|
||||||
|
let channel = client.channels.get(searchClues.users.id);
|
||||||
|
switch (channel?.channel_type) {
|
||||||
|
case 'Group':
|
||||||
|
case 'DirectMessage':
|
||||||
|
users = client.users.mapKeys(channel.recipients)
|
||||||
|
.filter(x => typeof x !== 'undefined') as User[];
|
||||||
|
break;
|
||||||
|
case 'TextChannel':
|
||||||
|
const server = channel.server;
|
||||||
|
users = client.servers.members.toArray()
|
||||||
|
.filter(x => x._id.substr(0, 26) === server)
|
||||||
|
.map(x => client.users.get(x._id.substr(26)))
|
||||||
|
.filter(x => typeof x !== 'undefined') as User[];
|
||||||
|
break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users = users.filter(x => x._id !== SYSTEM_USER_ID);
|
||||||
|
|
||||||
|
let matches = (search.length > 0 ? users.filter(user => user?.username.toLowerCase().match(regex)) : users)
|
||||||
|
.splice(0, 5)
|
||||||
|
.filter(x => typeof x !== "undefined");
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
let currentPosition =
|
||||||
|
state.type !== "none"
|
||||||
|
? state.selected
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
type: "user",
|
||||||
|
matches,
|
||||||
|
selected: Math.min(currentPosition, matches.length - 1),
|
||||||
|
within: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type !== "none") {
|
||||||
|
setState({ type: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCurrent(el: HTMLTextAreaElement) {
|
||||||
|
if (state.type !== "none") {
|
||||||
|
let result = findSearchString(el);
|
||||||
|
if (result) {
|
||||||
|
let [_type, search, index] = result;
|
||||||
|
|
||||||
|
let content = el.value.split("");
|
||||||
|
if (state.type === "emoji") {
|
||||||
|
content.splice(
|
||||||
|
index,
|
||||||
|
search.length,
|
||||||
|
state.matches[state.selected],
|
||||||
|
": "
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content.splice(
|
||||||
|
index - 1,
|
||||||
|
search.length + 1,
|
||||||
|
"<@",
|
||||||
|
state.matches[state.selected]._id,
|
||||||
|
"> "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(content.join(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
|
||||||
|
ev.preventDefault();
|
||||||
|
selectCurrent(document.querySelector("#message")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (focused && state.type !== 'none') {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.selected > 0) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: state.selected - 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.selected < state.matches.length - 1) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: state.selected + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCurrent(
|
||||||
|
e.currentTarget as HTMLTextAreaElement
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(e: KeyboardEvent) {
|
||||||
|
if (e.currentTarget !== null) {
|
||||||
|
// @ts-expect-error
|
||||||
|
onChange(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
|
||||||
|
setFocused(true);
|
||||||
|
onChange(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
if (state.type !== 'none' && state.within) return;
|
||||||
|
setFocused(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: focused ? state : { type: 'none' },
|
||||||
|
setState,
|
||||||
|
|
||||||
|
onClick,
|
||||||
|
onChange,
|
||||||
|
onKeyUp,
|
||||||
|
onKeyDown,
|
||||||
|
onFocus,
|
||||||
|
onBlur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Base = styled.div`
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--primary-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-direction: row;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
width: calc(100% - 12px);
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function AutoComplete({ state, setState, onClick }: Pick<AutoCompleteProps, 'state' | 'setState' | 'onClick'>) {
|
||||||
|
return (
|
||||||
|
<Base>
|
||||||
|
<div>
|
||||||
|
{state.type === "emoji" &&
|
||||||
|
state.matches.map((match, i) => (
|
||||||
|
<button
|
||||||
|
className={i === state.selected ? "active" : ''}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
(i !== state.selected ||
|
||||||
|
!state.within) &&
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: i,
|
||||||
|
within: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
state.within &&
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
within: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={onClick}>
|
||||||
|
<Emoji emoji={(emojiDictionary as any)[match]} size={20} />
|
||||||
|
:{match}:
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{state.type === "user" &&
|
||||||
|
state.matches.map((match, i) => (
|
||||||
|
<button
|
||||||
|
className={i === state.selected ? "active" : ''}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
(i !== state.selected ||
|
||||||
|
!state.within) &&
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: i,
|
||||||
|
within: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
state.within &&
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
within: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={onClick}>
|
||||||
|
<UserIcon
|
||||||
|
size={24}
|
||||||
|
target={match}
|
||||||
|
status={true} />
|
||||||
|
{match.username}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
)
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ function parseEmoji(emoji: string) {
|
||||||
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Emoji({ emoji, size }: { emoji: string, size?: number }) {
|
export default function Emoji({ emoji, size }: { emoji: string, size?: number }) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
alt={emoji}
|
alt={emoji}
|
|
@ -10,6 +10,8 @@ import { QueuedMessage } from "../../../redux/reducers/queue";
|
||||||
import { MessageObject } from "../../../context/revoltjs/util";
|
import { MessageObject } from "../../../context/revoltjs/util";
|
||||||
import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
|
import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
|
||||||
import Overline from "../../ui/Overline";
|
import Overline from "../../ui/Overline";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachContext?: boolean
|
attachContext?: boolean
|
||||||
|
@ -23,7 +25,8 @@ interface Props {
|
||||||
export default function Message({ attachContext, message, contrast, content: replacement, head, queued }: Props) {
|
export default function Message({ attachContext, message, contrast, content: replacement, head, queued }: Props) {
|
||||||
// TODO: Can improve re-renders here by providing a list
|
// TODO: Can improve re-renders here by providing a list
|
||||||
// TODO: of dependencies. We only need to update on u/avatar.
|
// TODO: of dependencies. We only need to update on u/avatar.
|
||||||
let user = useUser(message.author);
|
const user = useUser(message.author);
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const content = message.content as string;
|
const content = message.content as string;
|
||||||
return (
|
return (
|
||||||
|
@ -31,6 +34,7 @@ export default function Message({ attachContext, message, contrast, content: rep
|
||||||
head={head}
|
head={head}
|
||||||
contrast={contrast}
|
contrast={contrast}
|
||||||
sending={typeof queued !== 'undefined'}
|
sending={typeof queued !== 'undefined'}
|
||||||
|
mention={message.mentions?.includes(client.user!._id)}
|
||||||
failed={typeof queued?.error !== 'undefined'}
|
failed={typeof queued?.error !== 'undefined'}
|
||||||
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}>
|
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}>
|
||||||
<MessageInfo>
|
<MessageInfo>
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib
|
||||||
|
|
||||||
import FilePreview from './bars/FilePreview';
|
import FilePreview from './bars/FilePreview';
|
||||||
import { debounce } from "../../../lib/debounce";
|
import { debounce } from "../../../lib/debounce";
|
||||||
|
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
||||||
|
|
||||||
type Props = WithDispatcher & {
|
type Props = WithDispatcher & {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
|
@ -226,9 +227,11 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
|
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
|
||||||
|
const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = useAutoComplete(setMessage, { users: { type: 'channel', id: channel._id } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<AutoComplete {...autoCompleteProps} />
|
||||||
<FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' &&
|
<FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' &&
|
||||||
grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }),
|
grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }),
|
||||||
() => openScreen({ id: "error", error: "FileTooLarge" }), true)}
|
() => openScreen({ id: "error", error: "FileTooLarge" }), true)}
|
||||||
|
@ -271,7 +274,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
padding={14}
|
padding={14}
|
||||||
id="message"
|
id="message"
|
||||||
value={draft ?? ''}
|
value={draft ?? ''}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
|
if (onKeyDown(e)) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.key === "ArrowUp" &&
|
e.key === "ArrowUp" &&
|
||||||
(!draft || draft.length === 0)
|
(!draft || draft.length === 0)
|
||||||
|
@ -298,7 +304,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setMessage(e.currentTarget.value);
|
setMessage(e.currentTarget.value);
|
||||||
startTyping();
|
startTyping();
|
||||||
}} />
|
onChange(e);
|
||||||
|
}}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur} />
|
||||||
<Action>
|
<Action>
|
||||||
<IconButton onClick={send}>
|
<IconButton onClick={send}>
|
||||||
<Send size={20} />
|
<Send size={20} />
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import { RE_MENTIONS } from "revolt.js";
|
import { RE_MENTIONS } from "revolt.js";
|
||||||
import { generateEmoji } from "./Emoji";
|
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { MarkdownProps } from "./Markdown";
|
import { MarkdownProps } from "./Markdown";
|
||||||
import styles from "./Markdown.module.scss";
|
import styles from "./Markdown.module.scss";
|
||||||
|
import { generateEmoji } from "../common/Emoji";
|
||||||
import { internalEmit } from "../../lib/eventEmitter";
|
import { internalEmit } from "../../lib/eventEmitter";
|
||||||
|
import { emojiDictionary } from "../../assets/emojis";
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import Prism from "prismjs";
|
import Prism from "prismjs";
|
||||||
|
@ -47,7 +48,7 @@ export const md: MarkdownIt = MarkdownIt({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.disable("image")
|
.disable("image")
|
||||||
.use(MarkdownEmoji/*, { defs: emojiDictionary }*/)
|
.use(MarkdownEmoji, { defs: emojiDictionary })
|
||||||
.use(MarkdownSpoilers)
|
.use(MarkdownSpoilers)
|
||||||
.use(MarkdownSup)
|
.use(MarkdownSup)
|
||||||
.use(MarkdownSub)
|
.use(MarkdownSub)
|
||||||
|
|
|
@ -113,6 +113,11 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||||
dispatcher({ type: "LOGOUT" });
|
dispatcher({ type: "LOGOUT" });
|
||||||
|
|
||||||
delete client.user;
|
delete client.user;
|
||||||
|
// ! FIXME: write procedure client.clear();
|
||||||
|
client.users.clear();
|
||||||
|
client.channels.clear();
|
||||||
|
client.servers.clear();
|
||||||
|
client.servers.members.clear();
|
||||||
dispatcher({ type: "RESET" });
|
dispatcher({ type: "RESET" });
|
||||||
|
|
||||||
openScreen({ id: "none" });
|
openScreen({ id: "none" });
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import styles from "./Panes.module.scss";
|
import styles from "./Panes.module.scss";
|
||||||
import Tip from "../../../components/ui/Tip";
|
import Tip from "../../../components/ui/Tip";
|
||||||
|
import Emoji from "../../../components/common/Emoji";
|
||||||
import Checkbox from "../../../components/ui/Checkbox";
|
import Checkbox from "../../../components/ui/Checkbox";
|
||||||
import { connectState } from "../../../redux/connector";
|
import { connectState } from "../../../redux/connector";
|
||||||
import { WithDispatcher } from "../../../redux/reducers";
|
import { WithDispatcher } from "../../../redux/reducers";
|
||||||
import { Emoji } from "../../../components/markdown/Emoji";
|
|
||||||
import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale";
|
import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
Loading…
Reference in a new issue