client/packages/chat/src/components/message.tsx

158 lines
4.5 KiB
TypeScript

import { useCrypto } from "@tensamin/crypto/context";
import * as React from "react";
import { useChat } from "../context";
import type { RawMessage } from "../values";
import { log } from "@tensamin/shared/log";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Clock } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui/cmp/tooltip";
import { useIsMobile } from "@tensamin/ui/utils";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@tensamin/ui/cmp/context-menu";
function generateFixedLoadingSize(size: number, height: number) {
return size * 3 - (height / 20) * 11;
}
/**
* Executes Message.
* @param props Parameter props.
* @returns unknown.
*/
function MessageComponent(props: {
message: RawMessage & {
failed?: boolean;
};
notEncrypted?: boolean;
measureRef?: React.Ref<HTMLDivElement>;
}) {
const { sharedSecret } = useChat();
const { decrypt } = useCrypto();
const isMobile = useIsMobile();
const [decodedContent, setDecodedContent] = React.useState("");
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
if (props.notEncrypted) {
setDecodedContent(props.message.content);
setIsReady(true);
return;
}
const content = props.message.content;
const secret = sharedSecret;
let active = true;
if (!secret) {
setIsReady(false);
return;
}
decrypt(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, props.message.content, props.notEncrypted, sharedSecret]);
return (
<div
ref={props.measureRef}
className={`w-full flex justify-start transition-opacity duration-150 ${(props.message.failed || props.message.message_state === "awaiting") && "opacity-50"}`}
>
<ContextMenu>
<ContextMenuTrigger
render={
<div
className={`selection-foreground flex gap-1 items-center justify-center max-w-[80%] rounded-lg px-2 py-px whitespace-pre-wrap break-all ${
props.message.sent_by_self
? props.message.failed
? "bg-destructive/75 text-destructive-foreground"
: "bg-primary text-primary-foreground"
: "bg-muted"
} ${!isReady && "animate-pulse"} ${isMobile && "select-none"}`}
>
{props.message.failed &&
props.message.message_state === "awaiting" && (
<Tooltip>
<TooltipContent>
<p>Failed to send message</p>
</TooltipContent>
<TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip>
)}
{!props.message.failed &&
props.message.message_state === "awaiting" && (
<Clock size={17} />
)}
{isReady ? (
<Text value={decodedContent} />
) : (
<div
className="h-8"
style={{
width: generateFixedLoadingSize(
props.message.content.length,
props.message.height,
),
height: props.message.height,
}}
/>
)}
</div>
}
/>
<ContextMenuContent>
<ContextMenuItem>
<Text value={decodedContent} />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
);
}
export default React.memo(MessageComponent, (prev, next) => {
return (
prev.notEncrypted === next.notEncrypted &&
prev.measureRef === next.measureRef &&
prev.message.timestamp === next.message.timestamp &&
prev.message.content === next.message.content &&
prev.message.height === next.message.height &&
prev.message.sent_by_self === next.message.sent_by_self &&
prev.message.message_state === next.message.message_state &&
prev.message.failed === next.message.failed
);
});