Updated chat ui/ux

This commit is contained in:
Alois 2026-03-27 23:19:38 +01:00
commit 04f37be8f6
7 changed files with 146 additions and 32 deletions

View file

@ -6,6 +6,7 @@ import Crypto from "@tensamin/crypto/context";
import LegalWrapper from "@/features/legal/screen"; import LegalWrapper from "@/features/legal/screen";
import { Toaster } from "@tensamin/ui/cmp/sonner"; import { Toaster } from "@tensamin/ui/cmp/sonner";
import { TooltipProvider } from "@tensamin/ui/cmp/tooltip";
/** /**
* Executes Layout. * Executes Layout.
@ -42,11 +43,13 @@ export default function Layout(props: { children: ReactNode }) {
)} )}
</div> </div>
<Toaster /> <Toaster />
<TooltipProvider>
<Storage> <Storage>
<LegalWrapper> <LegalWrapper>
<Crypto>{props.children}</Crypto> <Crypto>{props.children}</Crypto>
</LegalWrapper> </LegalWrapper>
</Storage> </Storage>
</TooltipProvider>
</> </>
); );
} }

View file

@ -53,7 +53,7 @@ export default function InputComponent() {
return; return;
} }
addLiveMessage({ const reference = addLiveMessage({
height: 0, height: 0,
not_encrypted: true, not_encrypted: true,
timestamp: time, timestamp: time,
@ -75,6 +75,7 @@ export default function InputComponent() {
receiver_id: currentUserId, receiver_id: currentUserId,
send_time: time, send_time: time,
}); });
reference.setFailed(true);
toast("error", "Failed to send message"); toast("error", "Failed to send message");
}); });
@ -82,7 +83,7 @@ export default function InputComponent() {
} }
return ( return (
<Card className="rounded-none rounded-t-xl border border-input/50 border-b-0 pt-0"> <Card className="rounded-none rounded-t-xl border-b-0 pt-0">
<CardHeader className="p-0 flex flex-col"> <CardHeader className="p-0 flex flex-col">
<Input <Input
placeholder="Send a message..." placeholder="Send a message..."
@ -93,10 +94,7 @@ export default function InputComponent() {
/> />
<div className="w-full flex justify-between gap-2 p-2 pt-0"> <div className="w-full flex justify-between gap-2 p-2 pt-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button className="w-9 h-9 p-0" variant="ghost">
className="w-9 h-9 p-0"
variant="ghost"
>
<Plus size={20} /> <Plus size={20} />
</Button> </Button>
<div className="w-auto flex"> <div className="w-auto flex">
@ -104,16 +102,10 @@ export default function InputComponent() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button className="w-9 h-9 p-0" variant="ghost">
className="w-9 h-9 p-0"
variant="ghost"
>
<Laugh size={20} /> <Laugh size={20} />
</Button> </Button>
<Button <Button className="w-9 h-9 p-0" variant="ghost">
className="w-9 h-9 p-0"
variant="ghost"
>
<Clapperboard size={20} /> <Clapperboard size={20} />
</Button> </Button>
</div> </div>

View file

@ -4,6 +4,13 @@ import { useChat } from "../context";
import type { RawMessage } from "../values"; import type { RawMessage } from "../values";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { AlertTriangle } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui/cmp/tooltip";
/** /**
* Executes Message. * Executes Message.
@ -11,7 +18,9 @@ import Text from "@tensamin/markdown/text";
* @returns unknown. * @returns unknown.
*/ */
export default function Message(props: { export default function Message(props: {
message: RawMessage; message: RawMessage & {
failed?: boolean;
};
notEncrypted?: boolean; notEncrypted?: boolean;
}) { }) {
const { sharedSecret } = useChat(); const { sharedSecret } = useChat();
@ -67,14 +76,24 @@ export default function Message(props: {
}, [decrypt, props.message.content, props.notEncrypted, sharedSecret]); }, [decrypt, props.message.content, props.notEncrypted, sharedSecret]);
return ( return (
<div className="w-full flex justify-start">
<div <div
className={`animate-in fade-in duration-200 max-w-[80%] rounded-xl px-2 py-1 whitespace-pre-wrap wrap-break-word ${ className={`w-full flex justify-start ${props.message.failed && "opacity-50"}`}
>
<div
className={`flex gap-1 items-center justify-center animate-in fade-in duration-200 max-w-[80%] rounded-lg px-2 py-px whitespace-pre-wrap wrap-break-word ${
props.message.sent_by_self props.message.sent_by_self
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted" : "bg-muted"
}`} }`}
> >
{props.message.failed && (
<Tooltip>
<TooltipContent>
<p>Failed to send message</p>
</TooltipContent>
<TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip>
)}
{isReady ? <Text value={decodedContent} /> : null} {isReady ? <Text value={decodedContent} /> : null}
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouterState } from "@tanstack/react-router"; import { useRouterState } from "@tanstack/react-router";
import type { RawMessage, RawMessages } from "./values"; import type { LiveMessage, RawMessage, RawMessages } from "./values";
import { useCrypto } from "@tensamin/crypto/context"; import { useCrypto } from "@tensamin/crypto/context";
import { useUser } from "@tensamin/user/context"; import { useUser } from "@tensamin/user/context";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
@ -22,9 +22,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const { load } = useStorage(); const { load } = useStorage();
const { send } = useSocket(); const { send } = useSocket();
const [liveMessagesState, setLiveMessagesState] = React.useState<RawMessages>( const [liveMessagesState, setLiveMessagesState] = React.useState<LiveMessage[]>([]);
[],
);
const [currentSharedSecret, setCurrentSharedSecret] = React.useState(""); const [currentSharedSecret, setCurrentSharedSecret] = React.useState("");
const locationSearch = useRouterState({ const locationSearch = useRouterState({
@ -93,7 +91,30 @@ export default function Provider(props: { children: React.ReactNode }) {
); );
const addLiveMessage = React.useCallback((message: RawMessage) => { const addLiveMessage = React.useCallback((message: RawMessage) => {
setLiveMessagesState((prev) => [...prev, message]); const localId =
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
setLiveMessagesState((prev) => [
...prev,
{
...message,
localId,
failed: false,
},
]);
return {
setFailed: (failed: boolean) => {
setLiveMessagesState((prev) =>
prev.map((liveMessage) =>
liveMessage.localId === localId
? { ...liveMessage, failed }
: liveMessage,
),
);
},
};
}, []); }, []);
const clearLiveMessages = React.useCallback(() => { const clearLiveMessages = React.useCallback(() => {
@ -128,8 +149,10 @@ export default function Provider(props: { children: React.ReactNode }) {
type contextType = { type contextType = {
getMessages: (amount: number, offset: number) => Promise<RawMessages>; getMessages: (amount: number, offset: number) => Promise<RawMessages>;
liveMessages: () => RawMessages; liveMessages: () => LiveMessage[];
addLiveMessage: (message: RawMessage) => void; addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void;
};
clearLiveMessages: () => void; clearLiveMessages: () => void;
sharedSecret: () => string; sharedSecret: () => string;
userId: () => number; userId: () => number;

View file

@ -6,3 +6,8 @@ export type RawMessages = z.infer<
>["messages"]; >["messages"];
export type RawMessage = RawMessages[number]; export type RawMessage = RawMessages[number];
export type LiveMessage = RawMessage & {
failed?: boolean;
localId: string;
};

View file

@ -1,6 +1,7 @@
import { markdown } from "@codemirror/lang-markdown"; import { markdown } from "@codemirror/lang-markdown";
import { import {
EditorState, EditorState,
Prec,
type Extension, type Extension,
type Range, type Range,
type SelectionRange, type SelectionRange,
@ -81,6 +82,13 @@ export default function Input(props: InputProps) {
const elementRef = useRef<HTMLDivElement | null>(null); const elementRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | undefined>(undefined); const viewRef = useRef<EditorView | undefined>(undefined);
const ignoreSyncRef = useRef(false); const ignoreSyncRef = useRef(false);
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
useEffect(() => {
onSubmitRef.current = props.onSubmit;
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
}, [props.onSubmit, props.invertEnterBehavior]);
useEffect(() => { useEffect(() => {
if (!elementRef.current) return; if (!elementRef.current) return;
@ -93,8 +101,8 @@ export default function Input(props: InputProps) {
props.setValue(value); props.setValue(value);
}, },
() => props.placeholder, () => props.placeholder,
() => Boolean(props.invertEnterBehavior), () => invertEnterBehaviorRef.current,
() => props.onSubmit?.(), () => onSubmitRef.current?.(),
), ),
}); });
@ -179,8 +187,8 @@ function createEditorExtensions(
return [ return [
history(), history(),
markdown(), markdown(),
customEnterKeymap,
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
Prec.highest(customEnterKeymap),
EditorView.lineWrapping, EditorView.lineWrapping,
placeholder(getPlaceholder() ?? ""), placeholder(getPlaceholder() ?? ""),
EditorView.updateListener.of((update: ViewUpdate) => { EditorView.updateListener.of((update: ViewUpdate) => {

View file

@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "../lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }