Various UX & Chat improvements

This commit is contained in:
Alois 2026-04-05 16:06:47 +02:00
commit 6b96db8cfe
11 changed files with 229 additions and 221 deletions

View file

@ -9,6 +9,7 @@ import { useChat } from "../context";
import { useTTP } from "@tensamin/ttp/context";
import { useCrypto } from "@tensamin/crypto/context";
import { log, toast } from "@tensamin/shared/log";
import Message from "./message";
export default function InputComponent({
value,
@ -18,6 +19,7 @@ export default function InputComponent({
setValue: (value: string) => void;
}) {
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
const measurementRef = React.useRef<HTMLDivElement | null>(null);
const { encrypt } = useCrypto();
const { send } = useTTP();
@ -30,6 +32,21 @@ export default function InputComponent({
});
}, [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.
* @param none This function has no parameters.
@ -51,7 +68,7 @@ export default function InputComponent({
return;
}
const height = currentValue.split("\n").length * 24 + 16; // 24px per line + 16px padding
const height = getRenderedHeight();
const reference = addLiveMessage({
height,
@ -85,7 +102,25 @@ export default function InputComponent({
return (
<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">
<CardHeader className="relative p-0 flex flex-col">
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 w-full opacity-0"
style={{ visibility: "hidden" }}
>
<Message
measureRef={measurementRef}
message={{
height: 0,
not_encrypted: true,
timestamp: 0,
content: value,
sent_by_self: true,
message_state: "awaiting",
}}
notEncrypted
/>
</div>
<Input
placeholder="Send a message..."
value={value}

View file

@ -11,6 +11,17 @@ import {
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.
@ -22,10 +33,13 @@ export default function Message(props: {
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);
@ -75,38 +89,57 @@ export default function Message(props: {
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"}`}
>
<div
className={`flex gap-1 items-center justify-center max-w-[80%] rounded-lg px-2 py-px whitespace-pre-wrap wrap-break-word ${
props.message.sent_by_self
? props.message.failed
? "bg-destructive/75 text-destructive-foreground"
: "bg-primary text-primary-foreground"
: "bg-muted"
} ${!isReady && "animate-pulse"}`}
>
{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: Math.random() * 100 + 50 + "px",
}}
/>
)}
</div>
<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>
);
}

View file

@ -167,7 +167,7 @@ export default function Screen() {
window.innerHeight,
window.visualViewport?.height ?? 0,
);
const inputHeight = el.scrollHeight + 50;
const inputHeight = el.scrollHeight + 56;
host.style.maxHeight = `calc(${viewportHeight}px - ${inputHeight}px - env(safe-area-inset-bottom) - env(safe-area-inset-top))`;
}, []);