diff --git a/apps/web/src/routes/layout.tsx b/apps/web/src/routes/layout.tsx index 12620ec..7c877b6 100644 --- a/apps/web/src/routes/layout.tsx +++ b/apps/web/src/routes/layout.tsx @@ -6,6 +6,7 @@ import Crypto from "@tensamin/crypto/context"; import LegalWrapper from "@/features/legal/screen"; import { Toaster } from "@tensamin/ui/cmp/sonner"; +import { TooltipProvider } from "@tensamin/ui/cmp/tooltip"; /** * Executes Layout. @@ -42,11 +43,13 @@ export default function Layout(props: { children: ReactNode }) { )} - - - {props.children} - - + + + + {props.children} + + + ); } diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index c8e9c7a..8750a69 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -53,7 +53,7 @@ export default function InputComponent() { return; } - addLiveMessage({ + const reference = addLiveMessage({ height: 0, not_encrypted: true, timestamp: time, @@ -75,6 +75,7 @@ export default function InputComponent() { receiver_id: currentUserId, send_time: time, }); + reference.setFailed(true); toast("error", "Failed to send message"); }); @@ -82,7 +83,7 @@ export default function InputComponent() { } return ( - +
-
@@ -104,16 +102,10 @@ export default function InputComponent() {
- -
diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index edb64d5..8fe2f3c 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -4,6 +4,13 @@ import { useChat } from "../context"; import type { RawMessage } from "../values"; import { log } from "@tensamin/shared/log"; import Text from "@tensamin/markdown/text"; +import { AlertTriangle } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@tensamin/ui/cmp/tooltip"; /** * Executes Message. @@ -11,7 +18,9 @@ import Text from "@tensamin/markdown/text"; * @returns unknown. */ export default function Message(props: { - message: RawMessage; + message: RawMessage & { + failed?: boolean; + }; notEncrypted?: boolean; }) { const { sharedSecret } = useChat(); @@ -67,14 +76,24 @@ export default function Message(props: { }, [decrypt, props.message.content, props.notEncrypted, sharedSecret]); return ( -
+
+ {props.message.failed && ( + + +

Failed to send message

+
+ } /> +
+ )} {isReady ? : null}
diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 4345a7f..e102ef2 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { useUser } from "@tensamin/user/context"; import { useStorage } from "@tensamin/storage/context"; @@ -22,9 +22,7 @@ export default function Provider(props: { children: React.ReactNode }) { const { load } = useStorage(); const { send } = useSocket(); - const [liveMessagesState, setLiveMessagesState] = React.useState( - [], - ); + const [liveMessagesState, setLiveMessagesState] = React.useState([]); const [currentSharedSecret, setCurrentSharedSecret] = React.useState(""); const locationSearch = useRouterState({ @@ -93,7 +91,30 @@ export default function Provider(props: { children: React.ReactNode }) { ); 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(() => { @@ -128,8 +149,10 @@ export default function Provider(props: { children: React.ReactNode }) { type contextType = { getMessages: (amount: number, offset: number) => Promise; - liveMessages: () => RawMessages; - addLiveMessage: (message: RawMessage) => void; + liveMessages: () => LiveMessage[]; + addLiveMessage: (message: RawMessage) => { + setFailed: (failed: boolean) => void; + }; clearLiveMessages: () => void; sharedSecret: () => string; userId: () => number; diff --git a/packages/chat/src/values.ts b/packages/chat/src/values.ts index 76ad12e..692737b 100644 --- a/packages/chat/src/values.ts +++ b/packages/chat/src/values.ts @@ -6,3 +6,8 @@ export type RawMessages = z.infer< >["messages"]; export type RawMessage = RawMessages[number]; + +export type LiveMessage = RawMessage & { + failed?: boolean; + localId: string; +}; diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index 9878e63..5e93f10 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -1,6 +1,7 @@ import { markdown } from "@codemirror/lang-markdown"; import { EditorState, + Prec, type Extension, type Range, type SelectionRange, @@ -81,6 +82,13 @@ export default function Input(props: InputProps) { const elementRef = useRef(null); const viewRef = useRef(undefined); const ignoreSyncRef = useRef(false); + const onSubmitRef = useRef(props.onSubmit); + const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior)); + + useEffect(() => { + onSubmitRef.current = props.onSubmit; + invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior); + }, [props.onSubmit, props.invertEnterBehavior]); useEffect(() => { if (!elementRef.current) return; @@ -93,8 +101,8 @@ export default function Input(props: InputProps) { props.setValue(value); }, () => props.placeholder, - () => Boolean(props.invertEnterBehavior), - () => props.onSubmit?.(), + () => invertEnterBehaviorRef.current, + () => onSubmitRef.current?.(), ), }); @@ -179,8 +187,8 @@ function createEditorExtensions( return [ history(), markdown(), - customEnterKeymap, keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + Prec.highest(customEnterKeymap), EditorView.lineWrapping, placeholder(getPlaceholder() ?? ""), EditorView.updateListener.of((update: ViewUpdate) => { diff --git a/packages/ui/src/cmp/tooltip.tsx b/packages/ui/src/cmp/tooltip.tsx new file mode 100644 index 0000000..669e205 --- /dev/null +++ b/packages/ui/src/cmp/tooltip.tsx @@ -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 ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +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 ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }