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