Big chat improvements, adjusted mobile ui.
This commit is contained in:
parent
80114d1d66
commit
7a922a5978
15 changed files with 201 additions and 101 deletions
|
|
@ -10,8 +10,13 @@ import { useTTP } from "@tensamin/ttp/context";
|
|||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
|
||||
export default function InputComponent() {
|
||||
const [value, setValue] = React.useState("");
|
||||
export default function InputComponent({
|
||||
value,
|
||||
setValue,
|
||||
}: {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}) {
|
||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||
|
||||
const { encrypt } = useCrypto();
|
||||
|
|
@ -35,15 +40,13 @@ export default function InputComponent() {
|
|||
|
||||
const time = Date.now();
|
||||
const currentValue = value;
|
||||
const currentUserId = userId();
|
||||
const currentSharedSecret = sharedSecret();
|
||||
|
||||
if (!Number.isSafeInteger(currentUserId) || currentUserId <= 0) {
|
||||
if (!Number.isSafeInteger(userId) || userId <= 0) {
|
||||
toast("error", "No conversation selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSharedSecret) {
|
||||
if (!sharedSecret) {
|
||||
toast("error", "Still getting shared secret...");
|
||||
return;
|
||||
}
|
||||
|
|
@ -59,18 +62,18 @@ export default function InputComponent() {
|
|||
message_state: "awaiting",
|
||||
});
|
||||
|
||||
const encryptedContext = await encrypt(currentSharedSecret, currentValue);
|
||||
const encryptedContext = await encrypt(sharedSecret, currentValue);
|
||||
|
||||
send("message_send", {
|
||||
height,
|
||||
content: encryptedContext,
|
||||
receiver_id: currentUserId,
|
||||
receiver_id: userId,
|
||||
timestamp: time,
|
||||
}).catch((e) => {
|
||||
log(0, "Chat", "red", "Failed to send message", e, {
|
||||
content: currentValue,
|
||||
encryptedContext,
|
||||
receiver_id: currentUserId,
|
||||
receiver_id: userId,
|
||||
timestamp: time,
|
||||
});
|
||||
reference.setFailed(true);
|
||||
|
|
@ -81,7 +84,7 @@ export default function InputComponent() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-none rounded-t-xl border-b-0 pt-0">
|
||||
<Card className="rounded-none rounded-t-xl border-b-0 pt-0 pb-[env(safe-area-inset-bottom)]">
|
||||
<CardHeader className="p-0 flex flex-col">
|
||||
<Input
|
||||
placeholder="Send a message..."
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export default function Message(props: {
|
|||
}) {
|
||||
const { sharedSecret } = useChat();
|
||||
const { decrypt } = useCrypto();
|
||||
const sharedSecretValue = sharedSecret();
|
||||
|
||||
const [decodedContent, setDecodedContent] = React.useState("");
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
|
|
@ -38,7 +37,7 @@ export default function Message(props: {
|
|||
}
|
||||
|
||||
const content = props.message.content;
|
||||
const secret = sharedSecretValue;
|
||||
const secret = sharedSecret;
|
||||
let active = true;
|
||||
|
||||
if (!secret) {
|
||||
|
|
@ -72,7 +71,7 @@ export default function Message(props: {
|
|||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [decrypt, props.message.content, props.notEncrypted, sharedSecretValue]);
|
||||
}, [decrypt, props.message.content, props.notEncrypted, sharedSecret]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -83,9 +82,9 @@ export default function Message(props: {
|
|||
props.message.sent_by_self
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
} ${!isReady && "animate-pulse"}`}
|
||||
>
|
||||
{props.message.failed && (
|
||||
{props.message.failed && props.message.message_state === "awaiting" && (
|
||||
<Tooltip>
|
||||
<TooltipContent>
|
||||
<p>Failed to send message</p>
|
||||
|
|
@ -93,9 +92,18 @@ export default function Message(props: {
|
|||
<TooltipTrigger render={<AlertTriangle size={17} />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{props.message.message_state === "awaiting" &&
|
||||
!props.message.failed && <Clock size={17} />}
|
||||
{isReady ? <Text value={decodedContent} /> : null}
|
||||
{!props.message.failed &&
|
||||
props.message.message_state === "awaiting" && <Clock size={17} />}
|
||||
{isReady ? (
|
||||
<Text value={decodedContent} />
|
||||
) : (
|
||||
<div
|
||||
className="h-8"
|
||||
style={{
|
||||
width: Math.random() * 100 + 50 + "px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
useState,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRouterState } from "@tanstack/react-router";
|
||||
|
|
@ -15,6 +16,7 @@ import { useCrypto } from "@tensamin/crypto/context";
|
|||
import { useUser } from "@tensamin/user/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useTTP } from "@tensamin/ttp/context";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
|
|
@ -61,28 +63,38 @@ export default function Provider(props: { children: ReactNode }) {
|
|||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||
const [currentSharedSecret, setCurrentSharedSecret] = useState("");
|
||||
|
||||
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const locationSearch = useRouterState({
|
||||
select: (state) => state.location.search,
|
||||
});
|
||||
|
||||
// User ID compare to clear shared secret
|
||||
const userIdValue = useMemo(() => {
|
||||
const rawId = (locationSearch as unknown as { id?: unknown })?.id;
|
||||
return Number(rawId ?? 0);
|
||||
}, [locationSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const recipientId = userIdValue;
|
||||
const userIdValueFromLastRender = useRef(userIdValue);
|
||||
|
||||
if (!recipientId) {
|
||||
setCurrentSharedSecret("");
|
||||
useEffect(() => {
|
||||
if (userIdValue === userIdValueFromLastRender.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
userIdValueFromLastRender.current = userIdValue;
|
||||
setCurrentSharedSecret("");
|
||||
}, [userIdValue]);
|
||||
|
||||
// Load shared secret
|
||||
useEffect(() => {
|
||||
if (!userIdValue) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const recipientData = await get(recipientId);
|
||||
const recipientData = await get(userIdValue);
|
||||
const ownId = await load("user_id");
|
||||
const privateKey = await load("private_key");
|
||||
const ownData = await get(ownId);
|
||||
|
|
@ -193,10 +205,26 @@ export default function Provider(props: { children: ReactNode }) {
|
|||
!Number.isFinite(nextState.chat_partner_id) ||
|
||||
!Number.isFinite(nextState.timestamp)
|
||||
) {
|
||||
log(
|
||||
3,
|
||||
"chat",
|
||||
"yellow",
|
||||
"Cancel message state update due to invalid data",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState.chat_partner_id !== userIdValue) {
|
||||
log(
|
||||
3,
|
||||
"chat",
|
||||
"yellow",
|
||||
"Cancel message state update due to user ID mismatch",
|
||||
{
|
||||
expected: userIdValue,
|
||||
received: nextState.chat_partner_id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -246,28 +274,21 @@ export default function Provider(props: { children: ReactNode }) {
|
|||
});
|
||||
}, [subscribePush, userIdValue]);
|
||||
|
||||
const value = useMemo<contextType>(
|
||||
() => ({
|
||||
getMessages: customGetMessages,
|
||||
liveMessages: () => liveMessagesState,
|
||||
addLiveMessage,
|
||||
clearLiveMessages,
|
||||
sharedSecret: () => currentSharedSecret,
|
||||
userId: () => userIdValue,
|
||||
}),
|
||||
[
|
||||
addLiveMessage,
|
||||
clearLiveMessages,
|
||||
currentSharedSecret,
|
||||
customGetMessages,
|
||||
liveMessagesState,
|
||||
userIdValue,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<context.Provider value={value}>{props.children}</context.Provider>
|
||||
<context.Provider
|
||||
value={{
|
||||
getMessages: customGetMessages,
|
||||
liveMessages: () => liveMessagesState,
|
||||
addLiveMessage,
|
||||
clearLiveMessages,
|
||||
sharedSecret: currentSharedSecret,
|
||||
userId: userIdValue,
|
||||
inputBoxRef,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -279,8 +300,9 @@ type contextType = {
|
|||
setFailed: (failed: boolean) => void;
|
||||
};
|
||||
clearLiveMessages: () => void;
|
||||
sharedSecret: () => string;
|
||||
userId: () => number;
|
||||
sharedSecret: string;
|
||||
userId: number;
|
||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import InputComponent from "./components/input";
|
|||
import Message from "./components/message";
|
||||
|
||||
import { PAGE_SIZE } from "./values";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
/**
|
||||
* Renders the chat screen with virtualized history and live message updates.
|
||||
|
|
@ -15,6 +16,7 @@ import { PAGE_SIZE } from "./values";
|
|||
export default function Screen() {
|
||||
const { getMessages, liveMessages, clearLiveMessages, userId } = useChat();
|
||||
|
||||
const inputBoxRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
|
||||
|
|
@ -25,11 +27,10 @@ export default function Screen() {
|
|||
scrollTop: number;
|
||||
} | null>(null);
|
||||
|
||||
const chatUserId = userId();
|
||||
const hasValidChatUser = Number.isSafeInteger(chatUserId) && chatUserId > 0;
|
||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||
|
||||
const messagesQuery = useInfiniteQuery({
|
||||
queryKey: ["chat-messages", String(chatUserId)],
|
||||
queryKey: ["chat-messages", String(userId)],
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||
enabled: hasValidChatUser,
|
||||
|
|
@ -52,7 +53,7 @@ export default function Screen() {
|
|||
setHasScrolledToBottomInitially(false);
|
||||
setLastLiveMessageCount(0);
|
||||
setPrependAnchor(null);
|
||||
}, [chatUserId, clearLiveMessages]);
|
||||
}, [userId, clearLiveMessages]);
|
||||
|
||||
const liveMessagesSnapshot = liveMessages();
|
||||
|
||||
|
|
@ -152,6 +153,28 @@ export default function Screen() {
|
|||
void onScroll();
|
||||
}, [onScroll]);
|
||||
|
||||
/**
|
||||
* Input box height changes & state
|
||||
*/
|
||||
const [value, setValue] = React.useState("");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = inputBoxRef.current;
|
||||
const host = scrollRef.current;
|
||||
if (!host || !el) return;
|
||||
|
||||
host.style.maxHeight = `calc(100vh - ${el.scrollHeight + 50}px)`;
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = inputBoxRef.current;
|
||||
const host = scrollRef.current;
|
||||
if (!host || !el) return;
|
||||
|
||||
host.style.maxHeight = `calc(100vh - ${el.scrollHeight + 50}px)`;
|
||||
}, []);
|
||||
|
||||
// Render
|
||||
if (!hasValidChatUser) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
||||
|
|
@ -161,11 +184,11 @@ export default function Screen() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden px-2">
|
||||
<div className="w-full h-full flex flex-col px-2">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
id="chat_container"
|
||||
className="flex-1 max-h-[calc(100vh-151px)] overflow-y-auto px-2.5"
|
||||
className="flex-1 overflow-y-auto px-2.5"
|
||||
onScroll={handleContainerScroll}
|
||||
>
|
||||
<div
|
||||
|
|
@ -203,7 +226,9 @@ export default function Screen() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<InputComponent />
|
||||
<div ref={inputBoxRef}>
|
||||
<InputComponent setValue={setValue} value={value} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue