(feat): move decryption to getMessages instead of letting it get handled by the message component

This commit is contained in:
Alois 2026-05-01 11:16:23 +02:00
commit de63b554c1
4 changed files with 31 additions and 230 deletions

View file

@ -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}

View file

@ -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 &&

View file

@ -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,

View file

@ -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>