Big chat improvements, adjusted mobile ui.
This commit is contained in:
parent
80114d1d66
commit
7a922a5978
15 changed files with 201 additions and 101 deletions
|
|
@ -15,7 +15,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2"
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "tensamin",
|
"title": "Tensamin",
|
||||||
"width": 800,
|
"width": 800,
|
||||||
"height": 600
|
"height": 600
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { createContext } from "react";
|
||||||
import { createContext, useEffect } from "react";
|
|
||||||
|
|
||||||
type contextType = {
|
type contextType = {
|
||||||
isTauri: boolean;
|
nothing: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
export default function Provider({ children }: { children: React.ReactNode }) {
|
export default function Provider({ children }: { children: React.ReactNode }) {
|
||||||
const isTauriVar = isTauri();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTauriVar) return;
|
|
||||||
|
|
||||||
document.body.classList.add("");
|
|
||||||
}, [isTauriVar]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<context.Provider
|
<context.Provider
|
||||||
value={{
|
value={{
|
||||||
isTauri: isTauriVar,
|
nothing: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"@tensamin/ui": "workspace:*",
|
"@tensamin/ui": "workspace:*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/mobile": "workspace:*",
|
"@tensamin/mobile": "workspace:*",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"comlink": "^4.4.2",
|
"comlink": "^4.4.2",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button } from "@tensamin/ui/cmp/button";
|
import { Button } from "@tensamin/ui/cmp/button";
|
||||||
import { House, Phone, User } from "lucide-react";
|
import { ArrowLeft, House, Phone, User } from "lucide-react";
|
||||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { useCall } from "@tensamin/call/context";
|
import { useCall } from "@tensamin/call/context";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
|
@ -15,7 +15,7 @@ import { displayCallId } from "@tensamin/call/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SidebarTrigger, useSidebar } from "@tensamin/ui/cmp/sidebar";
|
import { SidebarTrigger, useSidebar } from "@tensamin/ui/cmp/sidebar";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { joinCall, state } = useCall();
|
const { joinCall, state } = useCall();
|
||||||
const { conversations } = useConversation();
|
const { conversations } = useConversation();
|
||||||
|
|
@ -29,14 +29,25 @@ export default function Navbar() {
|
||||||
const [selectOpen, setSelectOpen] = useState(false);
|
const [selectOpen, setSelectOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full gap-2 h-13.5 flex items-center justify-center">
|
<div
|
||||||
<Button
|
className={`${forMobile && "border-b"} w-full gap-2 h-13.5 flex items-center justify-center`}
|
||||||
onClick={() => navigate({ to: "/" })}
|
>
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
{forMobile ? (
|
||||||
variant="outline"
|
<SidebarTrigger
|
||||||
>
|
className="w-9 h-9 aspect-square rounded-lg ml-2"
|
||||||
<House className="size-4.5" />
|
variant="outline"
|
||||||
</Button>
|
>
|
||||||
|
<ArrowLeft className="size-4.5" />
|
||||||
|
</SidebarTrigger>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: "/" })}
|
||||||
|
className="w-9 h-9 aspect-square rounded-lg"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<House className="size-4.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{pathname === "/chat" && (
|
{pathname === "/chat" && (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={id}
|
userId={id}
|
||||||
|
|
@ -107,23 +118,25 @@ export function MobileNavbar() {
|
||||||
const { setOpenMobile } = useSidebar();
|
const { setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-100 w-full h-16 flex justify-center items-center gap-8 bg-card border-t">
|
<div className="z-100 w-full pb-[env(safe-area-inset-bottom)] bg-card border-t">
|
||||||
<SidebarTrigger
|
<div className="z-100 w-full h-14 flex justify-center items-center gap-8">
|
||||||
className="text-foreground w-12! h-12! aspect-square rounded-xl"
|
<SidebarTrigger
|
||||||
variant="link"
|
className="text-foreground w-12! h-12! aspect-square rounded-xl"
|
||||||
>
|
variant="link"
|
||||||
<User className="size-5.5" />
|
>
|
||||||
</SidebarTrigger>
|
<User className="size-5.5" />
|
||||||
<Button
|
</SidebarTrigger>
|
||||||
onClick={() => {
|
<Button
|
||||||
navigate({ to: "/" });
|
onClick={() => {
|
||||||
setOpenMobile(false);
|
navigate({ to: "/" });
|
||||||
}}
|
setOpenMobile(false);
|
||||||
className="text-foreground w-12 h-12 aspect-square rounded-xl"
|
}}
|
||||||
variant="link"
|
className="text-foreground w-12 h-12 aspect-square rounded-xl"
|
||||||
>
|
variant="link"
|
||||||
<House className="size-5.5" />
|
>
|
||||||
</Button>
|
<House className="size-5.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Sidebar as SidebarRoot,
|
Sidebar as SidebarRoot,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
} from "@tensamin/ui/cmp/sidebar";
|
} from "@tensamin/ui/cmp/sidebar";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the conversation sidebar with account summary and conversation list.
|
* Renders the conversation sidebar with account summary and conversation list.
|
||||||
|
|
@ -23,8 +24,8 @@ export default function Sidebar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarRoot>
|
<SidebarRoot>
|
||||||
<SidebarContent>
|
<SidebarContent className={isTauri() ? "pt-[env(safe-area-inset-top)]" : "pt-2"}>
|
||||||
<div className="h-full w-full flex flex-col gap-3 p-2">
|
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
|
||||||
<div>
|
<div>
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import {
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@tensamin/ui/cmp/context-menu";
|
} from "@tensamin/ui/cmp/context-menu";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useSidebar } from "@tensamin/ui/cmp/sidebar";
|
||||||
|
|
||||||
export default function ConversationModal({ userId }: { userId: number }) {
|
export default function ConversationModal({ userId }: { userId: number }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
|
|
@ -18,7 +20,10 @@ export default function ConversationModal({ userId }: { userId: number }) {
|
||||||
render={
|
render={
|
||||||
<div
|
<div
|
||||||
className="select-none cursor-pointer"
|
className="select-none cursor-pointer"
|
||||||
onClick={() => navigate({ to: "/chat", search: { id: userId } })}
|
onClick={() => {
|
||||||
|
navigate({ to: "/chat", search: { id: userId } });
|
||||||
|
setOpenMobile(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,17 @@ import { toast } from "@tensamin/shared/log";
|
||||||
import { useTTP } from "@tensamin/ttp/context";
|
import { useTTP } from "@tensamin/ttp/context";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
// The page
|
// The page
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="p-3 flex gap-2">
|
<div className={`px-3 flex gap-2 ${!isTauri() && "pt-3"}`}>
|
||||||
<AddConversationButton />
|
<AddConversationButton />
|
||||||
<Button disabled>Add Community</Button>
|
<Button disabled>Add Community</Button>
|
||||||
|
<Button variant="outline" onClick={() => location.reload()}>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { SidebarProvider } from "@tensamin/ui/cmp/sidebar";
|
||||||
import { useIsMobile, cn } from "@tensamin/ui/utils";
|
import { useIsMobile, cn } from "@tensamin/ui/utils";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { useLocation } from "@tanstack/react-router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes Layout.
|
* Executes Layout.
|
||||||
|
|
@ -15,6 +17,7 @@ import { motion } from "framer-motion";
|
||||||
*/
|
*/
|
||||||
export default function Layout(props: { children: ReactNode }) {
|
export default function Layout(props: { children: ReactNode }) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -33,8 +36,17 @@ export default function Layout(props: { children: ReactNode }) {
|
||||||
>
|
>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="w-full h-full flex flex-col">
|
<div
|
||||||
{!isMobile && <Navbar />}
|
className={cn(
|
||||||
|
"w-full h-full flex flex-col",
|
||||||
|
isTauri() && "pt-[env(safe-area-inset-top)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isMobile && <Navbar forMobile={false} />}
|
||||||
|
{isMobile && location.pathname === "/chat" && (
|
||||||
|
<Navbar forMobile={true} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background h-full w-full",
|
"bg-background h-full w-full",
|
||||||
|
|
@ -43,7 +55,7 @@ export default function Layout(props: { children: ReactNode }) {
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
{isMobile && <MobileNavbar />}
|
{isMobile && location.pathname !== "/chat" && <MobileNavbar />}
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { TooltipProvider } from "@tensamin/ui/cmp/tooltip";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useIsMobile } from "@tensamin/ui/utils";
|
import { useIsMobile } from "@tensamin/ui/utils";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes Layout.
|
* Executes Layout.
|
||||||
|
|
@ -51,7 +52,16 @@ export default function Layout(props: { children: ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
|
<Toaster
|
||||||
|
position={isMobile ? "top-center" : "bottom-right"}
|
||||||
|
{...(isTauri()
|
||||||
|
? {
|
||||||
|
mobileOffset: {
|
||||||
|
top: "env(safe-area-inset-top)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Storage>
|
<Storage>
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -35,6 +35,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
|
@ -49,6 +51,7 @@
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-router": "^1.0.0",
|
"@tanstack/react-router": "^1.0.0",
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
"@tensamin/call": "workspace:*",
|
"@tensamin/call": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ import { useTTP } from "@tensamin/ttp/context";
|
||||||
import { useCrypto } from "@tensamin/crypto/context";
|
import { useCrypto } from "@tensamin/crypto/context";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
|
|
||||||
export default function InputComponent() {
|
export default function InputComponent({
|
||||||
const [value, setValue] = React.useState("");
|
value,
|
||||||
|
setValue,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||||
|
|
||||||
const { encrypt } = useCrypto();
|
const { encrypt } = useCrypto();
|
||||||
|
|
@ -35,15 +40,13 @@ export default function InputComponent() {
|
||||||
|
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
const currentValue = value;
|
const currentValue = value;
|
||||||
const currentUserId = userId();
|
|
||||||
const currentSharedSecret = sharedSecret();
|
|
||||||
|
|
||||||
if (!Number.isSafeInteger(currentUserId) || currentUserId <= 0) {
|
if (!Number.isSafeInteger(userId) || userId <= 0) {
|
||||||
toast("error", "No conversation selected");
|
toast("error", "No conversation selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSharedSecret) {
|
if (!sharedSecret) {
|
||||||
toast("error", "Still getting shared secret...");
|
toast("error", "Still getting shared secret...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -59,18 +62,18 @@ export default function InputComponent() {
|
||||||
message_state: "awaiting",
|
message_state: "awaiting",
|
||||||
});
|
});
|
||||||
|
|
||||||
const encryptedContext = await encrypt(currentSharedSecret, currentValue);
|
const encryptedContext = await encrypt(sharedSecret, currentValue);
|
||||||
|
|
||||||
send("message_send", {
|
send("message_send", {
|
||||||
height,
|
height,
|
||||||
content: encryptedContext,
|
content: encryptedContext,
|
||||||
receiver_id: currentUserId,
|
receiver_id: userId,
|
||||||
timestamp: time,
|
timestamp: time,
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
log(0, "Chat", "red", "Failed to send message", e, {
|
log(0, "Chat", "red", "Failed to send message", e, {
|
||||||
content: currentValue,
|
content: currentValue,
|
||||||
encryptedContext,
|
encryptedContext,
|
||||||
receiver_id: currentUserId,
|
receiver_id: userId,
|
||||||
timestamp: time,
|
timestamp: time,
|
||||||
});
|
});
|
||||||
reference.setFailed(true);
|
reference.setFailed(true);
|
||||||
|
|
@ -81,7 +84,7 @@ export default function InputComponent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-none rounded-t-xl border-b-0 pt-0">
|
<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="p-0 flex flex-col">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export default function Message(props: {
|
||||||
}) {
|
}) {
|
||||||
const { sharedSecret } = useChat();
|
const { sharedSecret } = useChat();
|
||||||
const { decrypt } = useCrypto();
|
const { decrypt } = useCrypto();
|
||||||
const sharedSecretValue = sharedSecret();
|
|
||||||
|
|
||||||
const [decodedContent, setDecodedContent] = React.useState("");
|
const [decodedContent, setDecodedContent] = React.useState("");
|
||||||
const [isReady, setIsReady] = React.useState(false);
|
const [isReady, setIsReady] = React.useState(false);
|
||||||
|
|
@ -38,7 +37,7 @@ export default function Message(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = props.message.content;
|
const content = props.message.content;
|
||||||
const secret = sharedSecretValue;
|
const secret = sharedSecret;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
|
|
@ -72,7 +71,7 @@ export default function Message(props: {
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [decrypt, props.message.content, props.notEncrypted, sharedSecretValue]);
|
}, [decrypt, props.message.content, props.notEncrypted, sharedSecret]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -83,9 +82,9 @@ export default function Message(props: {
|
||||||
props.message.sent_by_self
|
props.message.sent_by_self
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted"
|
: "bg-muted"
|
||||||
}`}
|
} ${!isReady && "animate-pulse"}`}
|
||||||
>
|
>
|
||||||
{props.message.failed && (
|
{props.message.failed && props.message.message_state === "awaiting" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Failed to send message</p>
|
<p>Failed to send message</p>
|
||||||
|
|
@ -93,9 +92,18 @@ export default function Message(props: {
|
||||||
<TooltipTrigger render={<AlertTriangle size={17} />} />
|
<TooltipTrigger render={<AlertTriangle size={17} />} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{props.message.message_state === "awaiting" &&
|
{!props.message.failed &&
|
||||||
!props.message.failed && <Clock size={17} />}
|
props.message.message_state === "awaiting" && <Clock size={17} />}
|
||||||
{isReady ? <Text value={decodedContent} /> : null}
|
{isReady ? (
|
||||||
|
<Text value={decodedContent} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-8"
|
||||||
|
style={{
|
||||||
|
width: Math.random() * 100 + 50 + "px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
useRef,
|
||||||
} from "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";
|
||||||
|
|
@ -15,6 +16,7 @@ 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";
|
||||||
import { useTTP } from "@tensamin/ttp/context";
|
import { useTTP } from "@tensamin/ttp/context";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -61,28 +63,38 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
const [currentSharedSecret, setCurrentSharedSecret] = useState("");
|
const [currentSharedSecret, setCurrentSharedSecret] = useState("");
|
||||||
|
|
||||||
|
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const locationSearch = useRouterState({
|
const locationSearch = useRouterState({
|
||||||
select: (state) => state.location.search,
|
select: (state) => state.location.search,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User ID compare to clear shared secret
|
||||||
const userIdValue = useMemo(() => {
|
const userIdValue = useMemo(() => {
|
||||||
const rawId = (locationSearch as unknown as { id?: unknown })?.id;
|
const rawId = (locationSearch as unknown as { id?: unknown })?.id;
|
||||||
return Number(rawId ?? 0);
|
return Number(rawId ?? 0);
|
||||||
}, [locationSearch]);
|
}, [locationSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
const userIdValueFromLastRender = useRef(userIdValue);
|
||||||
const recipientId = userIdValue;
|
|
||||||
|
|
||||||
if (!recipientId) {
|
useEffect(() => {
|
||||||
setCurrentSharedSecret("");
|
if (userIdValue === userIdValueFromLastRender.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userIdValueFromLastRender.current = userIdValue;
|
||||||
|
setCurrentSharedSecret("");
|
||||||
|
}, [userIdValue]);
|
||||||
|
|
||||||
|
// Load shared secret
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userIdValue) return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const recipientData = await get(recipientId);
|
const recipientData = await get(userIdValue);
|
||||||
const ownId = await load("user_id");
|
const ownId = await load("user_id");
|
||||||
const privateKey = await load("private_key");
|
const privateKey = await load("private_key");
|
||||||
const ownData = await get(ownId);
|
const ownData = await get(ownId);
|
||||||
|
|
@ -193,10 +205,26 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
!Number.isFinite(nextState.chat_partner_id) ||
|
!Number.isFinite(nextState.chat_partner_id) ||
|
||||||
!Number.isFinite(nextState.timestamp)
|
!Number.isFinite(nextState.timestamp)
|
||||||
) {
|
) {
|
||||||
|
log(
|
||||||
|
3,
|
||||||
|
"chat",
|
||||||
|
"yellow",
|
||||||
|
"Cancel message state update due to invalid data",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextState.chat_partner_id !== userIdValue) {
|
if (nextState.chat_partner_id !== userIdValue) {
|
||||||
|
log(
|
||||||
|
3,
|
||||||
|
"chat",
|
||||||
|
"yellow",
|
||||||
|
"Cancel message state update due to user ID mismatch",
|
||||||
|
{
|
||||||
|
expected: userIdValue,
|
||||||
|
received: nextState.chat_partner_id,
|
||||||
|
}
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,28 +274,21 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}, [subscribePush, userIdValue]);
|
}, [subscribePush, userIdValue]);
|
||||||
|
|
||||||
const value = useMemo<contextType>(
|
|
||||||
() => ({
|
|
||||||
getMessages: customGetMessages,
|
|
||||||
liveMessages: () => liveMessagesState,
|
|
||||||
addLiveMessage,
|
|
||||||
clearLiveMessages,
|
|
||||||
sharedSecret: () => currentSharedSecret,
|
|
||||||
userId: () => userIdValue,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
addLiveMessage,
|
|
||||||
clearLiveMessages,
|
|
||||||
currentSharedSecret,
|
|
||||||
customGetMessages,
|
|
||||||
liveMessagesState,
|
|
||||||
userIdValue,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<context.Provider value={value}>{props.children}</context.Provider>
|
<context.Provider
|
||||||
|
value={{
|
||||||
|
getMessages: customGetMessages,
|
||||||
|
liveMessages: () => liveMessagesState,
|
||||||
|
addLiveMessage,
|
||||||
|
clearLiveMessages,
|
||||||
|
sharedSecret: currentSharedSecret,
|
||||||
|
userId: userIdValue,
|
||||||
|
inputBoxRef,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</context.Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -279,8 +300,9 @@ type contextType = {
|
||||||
setFailed: (failed: boolean) => void;
|
setFailed: (failed: boolean) => void;
|
||||||
};
|
};
|
||||||
clearLiveMessages: () => void;
|
clearLiveMessages: () => void;
|
||||||
sharedSecret: () => string;
|
sharedSecret: string;
|
||||||
userId: () => number;
|
userId: number;
|
||||||
|
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import InputComponent from "./components/input";
|
||||||
import Message from "./components/message";
|
import Message from "./components/message";
|
||||||
|
|
||||||
import { PAGE_SIZE } from "./values";
|
import { PAGE_SIZE } from "./values";
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the chat screen with virtualized history and live message updates.
|
* Renders the chat screen with virtualized history and live message updates.
|
||||||
|
|
@ -15,6 +16,7 @@ import { PAGE_SIZE } from "./values";
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const { getMessages, liveMessages, clearLiveMessages, userId } = useChat();
|
const { getMessages, liveMessages, clearLiveMessages, userId } = useChat();
|
||||||
|
|
||||||
|
const inputBoxRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
|
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
|
||||||
|
|
@ -25,11 +27,10 @@ export default function Screen() {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const chatUserId = userId();
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasValidChatUser = Number.isSafeInteger(chatUserId) && chatUserId > 0;
|
|
||||||
|
|
||||||
const messagesQuery = useInfiniteQuery({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(chatUserId)],
|
queryKey: ["chat-messages", String(userId)],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||||
enabled: hasValidChatUser,
|
enabled: hasValidChatUser,
|
||||||
|
|
@ -52,7 +53,7 @@ export default function Screen() {
|
||||||
setHasScrolledToBottomInitially(false);
|
setHasScrolledToBottomInitially(false);
|
||||||
setLastLiveMessageCount(0);
|
setLastLiveMessageCount(0);
|
||||||
setPrependAnchor(null);
|
setPrependAnchor(null);
|
||||||
}, [chatUserId, clearLiveMessages]);
|
}, [userId, clearLiveMessages]);
|
||||||
|
|
||||||
const liveMessagesSnapshot = liveMessages();
|
const liveMessagesSnapshot = liveMessages();
|
||||||
|
|
||||||
|
|
@ -152,6 +153,28 @@ export default function Screen() {
|
||||||
void onScroll();
|
void onScroll();
|
||||||
}, [onScroll]);
|
}, [onScroll]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input box height changes & state
|
||||||
|
*/
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = inputBoxRef.current;
|
||||||
|
const host = scrollRef.current;
|
||||||
|
if (!host || !el) return;
|
||||||
|
|
||||||
|
host.style.maxHeight = `calc(100vh - ${el.scrollHeight + 50}px)`;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = inputBoxRef.current;
|
||||||
|
const host = scrollRef.current;
|
||||||
|
if (!host || !el) return;
|
||||||
|
|
||||||
|
host.style.maxHeight = `calc(100vh - ${el.scrollHeight + 50}px)`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render
|
||||||
if (!hasValidChatUser) {
|
if (!hasValidChatUser) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
||||||
|
|
@ -161,11 +184,11 @@ export default function Screen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col overflow-hidden px-2">
|
<div className="w-full h-full flex flex-col px-2">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
id="chat_container"
|
id="chat_container"
|
||||||
className="flex-1 max-h-[calc(100vh-151px)] overflow-y-auto px-2.5"
|
className="flex-1 overflow-y-auto px-2.5"
|
||||||
onScroll={handleContainerScroll}
|
onScroll={handleContainerScroll}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -203,7 +226,9 @@ export default function Screen() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<InputComponent />
|
<div ref={inputBoxRef}>
|
||||||
|
<InputComponent setValue={setValue} value={value} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue