(feat): move decryption to getMessages instead of letting it get handled by the message component
This commit is contained in:
parent
2f0072f422
commit
de63b554c1
4 changed files with 31 additions and 230 deletions
|
|
@ -8,7 +8,6 @@ import { Plus, Laugh, Clapperboard } from "lucide-react";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import { useTTP } from "@tensamin/ttp";
|
import { useTTP } from "@tensamin/ttp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import Message from "./message";
|
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
import { cn, useIsMobile } from "@tensamin/ui";
|
||||||
import { encryptText } from "@tensamin/crypto/worker";
|
import { encryptText } from "@tensamin/crypto/worker";
|
||||||
|
|
||||||
|
|
@ -20,7 +19,6 @@ export default function InputComponent({
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||||
const measurementRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { send } = useTTP();
|
const { send } = useTTP();
|
||||||
const { addLiveMessage, sharedSecret, userId } = useChat();
|
const { addLiveMessage, sharedSecret, userId } = useChat();
|
||||||
|
|
@ -32,21 +30,6 @@ export default function InputComponent({
|
||||||
});
|
});
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const getRenderedHeight = React.useCallback(() => {
|
|
||||||
const measuredHeight =
|
|
||||||
measurementRef.current?.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof measuredHeight === "number" &&
|
|
||||||
Number.isFinite(measuredHeight) &&
|
|
||||||
measuredHeight > 0
|
|
||||||
) {
|
|
||||||
return Math.ceil(measuredHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 40;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes handleSubmit.
|
* Executes handleSubmit.
|
||||||
* @param none This function has no parameters.
|
* @param none This function has no parameters.
|
||||||
|
|
@ -68,10 +51,8 @@ export default function InputComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const height = getRenderedHeight();
|
|
||||||
|
|
||||||
const reference = addLiveMessage({
|
const reference = addLiveMessage({
|
||||||
height,
|
height: 0,
|
||||||
not_encrypted: true,
|
not_encrypted: true,
|
||||||
send_time: time,
|
send_time: time,
|
||||||
content: currentValue,
|
content: currentValue,
|
||||||
|
|
@ -82,7 +63,7 @@ export default function InputComponent({
|
||||||
const encryptedContext = await encryptText(sharedSecret, currentValue);
|
const encryptedContext = await encryptText(sharedSecret, currentValue);
|
||||||
|
|
||||||
send("message_send", {
|
send("message_send", {
|
||||||
height,
|
height: 0,
|
||||||
content: encryptedContext,
|
content: encryptedContext,
|
||||||
receiver_id: userId,
|
receiver_id: userId,
|
||||||
send_time: time,
|
send_time: time,
|
||||||
|
|
@ -112,24 +93,6 @@ export default function InputComponent({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-0 top-0 w-full px-2.5 opacity-0"
|
|
||||||
style={{ visibility: "hidden" }}
|
|
||||||
>
|
|
||||||
<Message
|
|
||||||
measureRef={measurementRef}
|
|
||||||
message={{
|
|
||||||
height: 0,
|
|
||||||
not_encrypted: true,
|
|
||||||
send_time: 0,
|
|
||||||
content: value,
|
|
||||||
sent_by_self: true,
|
|
||||||
message_state: "awaiting",
|
|
||||||
}}
|
|
||||||
notEncrypted
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
value={value}
|
value={value}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useCrypto } from "@tensamin/crypto/context";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useChat } from "../context";
|
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle, Clock } from "lucide-react";
|
import { AlertTriangle, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -14,92 +11,18 @@ import {
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import { decryptText } from "@tensamin/crypto/worker";
|
|
||||||
|
|
||||||
function generateFixedLoadingSize(size: number, height: number) {
|
|
||||||
return size * 3 - (height / 20) * 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSafeMessageHeight(height: number | undefined) {
|
|
||||||
if (typeof height === "number" && Number.isFinite(height) && height > 0) {
|
|
||||||
return Math.ceil(height);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes Message.
|
|
||||||
* @param props Parameter props.
|
|
||||||
* @returns unknown.
|
|
||||||
*/
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
message,
|
message,
|
||||||
notEncrypted,
|
|
||||||
measureRef,
|
|
||||||
}: {
|
}: {
|
||||||
message: RawMessage & {
|
message: RawMessage & {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
};
|
};
|
||||||
notEncrypted?: boolean;
|
|
||||||
measureRef?: React.Ref<HTMLDivElement>;
|
|
||||||
}) {
|
}) {
|
||||||
const { sharedSecret } = useChat();
|
|
||||||
const { decrypt } = useCrypto();
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [decodedContent, setDecodedContent] = React.useState("");
|
|
||||||
const [isReady, setIsReady] = React.useState(false);
|
|
||||||
const safeMessageHeight = getSafeMessageHeight(message.height);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (notEncrypted) {
|
|
||||||
setDecodedContent(message.content);
|
|
||||||
setIsReady(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = message.content;
|
|
||||||
const secret = sharedSecret;
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
if (!secret) {
|
|
||||||
setIsReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptText(secret, content)
|
|
||||||
.then((value) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDecodedContent(value);
|
|
||||||
setIsReady(true);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(0, "Chat", "red", "Failed to decrypt message", e, {
|
|
||||||
content,
|
|
||||||
secret,
|
|
||||||
});
|
|
||||||
|
|
||||||
setDecodedContent("Failed to decrypt");
|
|
||||||
setIsReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [decrypt, message.content, notEncrypted, sharedSecret]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={measureRef}
|
|
||||||
className={`w-full flex justify-start transition-opacity duration-150 ${(message.failed || message.message_state === "awaiting") && "opacity-50"}`}
|
className={`w-full flex justify-start transition-opacity duration-150 ${(message.failed || message.message_state === "awaiting") && "opacity-50"}`}
|
||||||
>
|
>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
|
|
@ -112,7 +35,7 @@ function MessageComponent({
|
||||||
? "bg-destructive/75 text-destructive-foreground"
|
? "bg-destructive/75 text-destructive-foreground"
|
||||||
: "bg-primary text-primary-foreground"
|
: "bg-primary text-primary-foreground"
|
||||||
: "bg-muted"
|
: "bg-muted"
|
||||||
} ${!isReady && "animate-pulse"} ${isMobile && "select-none"}`}
|
} ${isMobile && "select-none"}`}
|
||||||
>
|
>
|
||||||
{message.failed && message.message_state === "awaiting" && (
|
{message.failed && message.message_state === "awaiting" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -125,26 +48,13 @@ function MessageComponent({
|
||||||
{!message.failed && message.message_state === "awaiting" && (
|
{!message.failed && message.message_state === "awaiting" && (
|
||||||
<Clock size={17} />
|
<Clock size={17} />
|
||||||
)}
|
)}
|
||||||
{isReady ? (
|
<Text value={message.content} />
|
||||||
<Text value={decodedContent} />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="block rounded-sm"
|
|
||||||
style={{
|
|
||||||
width: generateFixedLoadingSize(
|
|
||||||
message.content.length,
|
|
||||||
safeMessageHeight,
|
|
||||||
),
|
|
||||||
minHeight: safeMessageHeight,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem>
|
<ContextMenuItem>
|
||||||
<Text value={decodedContent} />
|
<Text value={message.content} />
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
@ -154,8 +64,6 @@ function MessageComponent({
|
||||||
|
|
||||||
export default React.memo(MessageComponent, (prev, next) => {
|
export default React.memo(MessageComponent, (prev, next) => {
|
||||||
return (
|
return (
|
||||||
prev.notEncrypted === next.notEncrypted &&
|
|
||||||
prev.measureRef === next.measureRef &&
|
|
||||||
prev.message.send_time === next.message.send_time &&
|
prev.message.send_time === next.message.send_time &&
|
||||||
prev.message.content === next.message.content &&
|
prev.message.content === next.message.content &&
|
||||||
prev.message.height === next.message.height &&
|
prev.message.height === next.message.height &&
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ function updateMessageStateBySendTime<
|
||||||
* @returns unknown.
|
* @returns unknown.
|
||||||
*/
|
*/
|
||||||
export default function Provider(props: { children: ReactNode }) {
|
export default function Provider(props: { children: ReactNode }) {
|
||||||
const { getSharedSecret } = useCrypto();
|
const { getSharedSecret, decryptText } = useCrypto();
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send, subscribePush } = useTTP();
|
const { send, subscribePush } = useTTP();
|
||||||
|
|
@ -128,7 +128,7 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, [get, getSharedSecret, load, userIdValue]);
|
}, [get, getSharedSecret, load, userIdValue]);
|
||||||
|
|
||||||
const customGetMessages = useCallback(
|
const getMessages = useCallback(
|
||||||
async (amount: number, offset: number) => {
|
async (amount: number, offset: number) => {
|
||||||
const messages = await send("messages_get", {
|
const messages = await send("messages_get", {
|
||||||
amount,
|
amount,
|
||||||
|
|
@ -155,9 +155,20 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return sorted;
|
return await Promise.all(
|
||||||
|
sorted.map(async (message) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: await decryptText(currentSharedSecret, message.content),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[send, userIdValue],
|
[send, userIdValue, currentSharedSecret, decryptText],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addLiveMessage = useCallback((message: RawMessage) => {
|
const addLiveMessage = useCallback((message: RawMessage) => {
|
||||||
|
|
@ -287,7 +298,7 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<context.Provider
|
<context.Provider
|
||||||
value={{
|
value={{
|
||||||
getMessages: customGetMessages,
|
getMessages,
|
||||||
liveMessages: () => liveMessagesState,
|
liveMessages: () => liveMessagesState,
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,23 @@ import InputComponent from "./components/input";
|
||||||
import Message from "./components/message";
|
import Message from "./components/message";
|
||||||
|
|
||||||
import { PAGE_SIZE } from "./values";
|
import { PAGE_SIZE } from "./values";
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
|
||||||
function getDistanceFromBottom(element: HTMLDivElement) {
|
function getDistanceFromBottom(element: HTMLDivElement) {
|
||||||
return element.scrollHeight - (element.scrollTop + element.clientHeight);
|
return element.scrollHeight - (element.scrollTop + element.clientHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_MESSAGE_HEIGHT = 40;
|
const MESSAGE_ROW_HEIGHT = 48;
|
||||||
const ROW_VERTICAL_PADDING = 8;
|
const ROW_VERTICAL_PADDING = 8;
|
||||||
|
|
||||||
function getSafeMessageHeight(height: number | undefined) {
|
|
||||||
if (typeof height === "number" && Number.isFinite(height) && height > 0) {
|
|
||||||
return Math.ceil(height);
|
|
||||||
}
|
|
||||||
|
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the chat screen with virtualized history and live message updates.
|
* Renders the chat screen with virtualized history and live message updates.
|
||||||
* @returns Chat screen JSX.
|
* @returns Chat screen JSX.
|
||||||
*/
|
*/
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const { getMessages, liveMessages, clearLiveMessages, userId } = useChat();
|
const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } =
|
||||||
|
useChat();
|
||||||
|
|
||||||
const inputBoxRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const stickyBottomThreshold = isMobile ? 220 : 140;
|
const stickyBottomThreshold = isMobile ? 220 : 140;
|
||||||
|
|
@ -46,12 +37,13 @@ export default function Screen() {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
|
const hasSharedSecret = sharedSecret.length > 0;
|
||||||
|
|
||||||
const messagesQuery = useInfiniteQuery({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(userId)],
|
queryKey: ["chat-messages", String(userId), hasSharedSecret],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||||
enabled: hasValidChatUser,
|
enabled: hasValidChatUser && hasSharedSecret,
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
if (lastPage.length < PAGE_SIZE) {
|
if (lastPage.length < PAGE_SIZE) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -105,10 +97,7 @@ export default function Screen() {
|
||||||
count: messages.length,
|
count: messages.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
estimateSize: (index) => {
|
estimateSize: () => MESSAGE_ROW_HEIGHT + ROW_VERTICAL_PADDING,
|
||||||
const message = messages[index];
|
|
||||||
return getSafeMessageHeight(message?.height) + ROW_VERTICAL_PADDING;
|
|
||||||
},
|
|
||||||
overscan: isMobile ? 10 : 6,
|
overscan: isMobile ? 10 : 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -195,72 +184,8 @@ export default function Screen() {
|
||||||
void onScroll();
|
void onScroll();
|
||||||
}, [onScroll]);
|
}, [onScroll]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Input box height changes & state
|
|
||||||
*/
|
|
||||||
const [value, setValue] = React.useState("");
|
const [value, setValue] = React.useState("");
|
||||||
|
|
||||||
const updateMaxHeight = React.useCallback(() => {
|
|
||||||
const el = inputBoxRef.current;
|
|
||||||
const host = scrollRef.current;
|
|
||||||
if (!host || !el || typeof window === "undefined") return;
|
|
||||||
|
|
||||||
const shouldStickToBottom =
|
|
||||||
getDistanceFromBottom(host) <= stickyBottomThreshold;
|
|
||||||
|
|
||||||
const viewportHeight = Math.max(
|
|
||||||
window.innerHeight,
|
|
||||||
window.visualViewport?.height ?? 0,
|
|
||||||
);
|
|
||||||
const inputHeight = el.scrollHeight + 56;
|
|
||||||
|
|
||||||
host.style.maxHeight = `calc(${viewportHeight}px - ${inputHeight - 62}px - env(safe-area-inset-bottom) - env(safe-area-inset-top))`;
|
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [stickyBottomThreshold]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
updateMaxHeight();
|
|
||||||
}, [value, updateMaxHeight]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
updateMaxHeight();
|
|
||||||
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleViewportChange = () => {
|
|
||||||
updateMaxHeight();
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewport = window.visualViewport;
|
|
||||||
viewport?.addEventListener("resize", handleViewportChange);
|
|
||||||
window.addEventListener("resize", handleViewportChange);
|
|
||||||
|
|
||||||
const input = inputBoxRef.current;
|
|
||||||
const resizeObserver = input
|
|
||||||
? new ResizeObserver(handleViewportChange)
|
|
||||||
: null;
|
|
||||||
if (input) {
|
|
||||||
resizeObserver?.observe(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
viewport?.removeEventListener("resize", handleViewportChange);
|
|
||||||
window.removeEventListener("resize", handleViewportChange);
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
};
|
|
||||||
}, [updateMaxHeight]);
|
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
if (!hasValidChatUser) {
|
if (!hasValidChatUser) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -275,7 +200,7 @@ export default function Screen() {
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
id="chat_container"
|
id="chat_container"
|
||||||
className="flex-1 overflow-y-auto px-2.5"
|
className="flex-1 overflow-y-auto px-2.5 pb-32"
|
||||||
style={{
|
style={{
|
||||||
overflowAnchor: "none",
|
overflowAnchor: "none",
|
||||||
}}
|
}}
|
||||||
|
|
@ -307,21 +232,15 @@ export default function Screen() {
|
||||||
padding: "4px 0",
|
padding: "4px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Message
|
<Message message={message} />
|
||||||
message={message}
|
|
||||||
notEncrypted={message.not_encrypted}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={inputBoxRef}
|
|
||||||
className="fixed -bottom-15 pb-15"
|
className="fixed -bottom-15 pb-15"
|
||||||
style={{
|
style={{ width: "calc(100vw - 277px)" }}
|
||||||
width: "calc(100vw - 277px)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<InputComponent setValue={setValue} value={value} />
|
<InputComponent setValue={setValue} value={value} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue