diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f893c00..3bd7416 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,7 +15,9 @@ }, "dependencies": { "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-opener": "^2", + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/apps/mobile/src-tauri/tauri.conf.json b/apps/mobile/src-tauri/tauri.conf.json index 51a52e1..1176b65 100644 --- a/apps/mobile/src-tauri/tauri.conf.json +++ b/apps/mobile/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "withGlobalTauri": true, "windows": [ { - "title": "tensamin", + "title": "Tensamin", "width": 800, "height": 600 } diff --git a/apps/mobile/src/context.tsx b/apps/mobile/src/context.tsx index edb978e..5e5b560 100644 --- a/apps/mobile/src/context.tsx +++ b/apps/mobile/src/context.tsx @@ -1,25 +1,16 @@ -import { isTauri } from "@tauri-apps/api/core"; -import { createContext, useEffect } from "react"; +import { createContext } from "react"; type contextType = { - isTauri: boolean; + nothing: undefined; }; export const context = createContext(undefined); export default function Provider({ children }: { children: React.ReactNode }) { - const isTauriVar = isTauri(); - - useEffect(() => { - if (!isTauriVar) return; - - document.body.classList.add(""); - }, [isTauriVar]); - return ( {children} diff --git a/apps/web/package.json b/apps/web/package.json index f3249bc..f0e20cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@tensamin/ui": "workspace:*", "@tensamin/user": "workspace:*", "@tensamin/mobile": "workspace:*", + "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "comlink": "^4.4.2", diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 217bf98..d8feb9a 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -1,5 +1,5 @@ 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 { useCall } from "@tensamin/call/context"; import Wrapper from "@tensamin/user/wrapper"; @@ -15,7 +15,7 @@ import { displayCallId } from "@tensamin/call/utils"; import { useState } from "react"; import { SidebarTrigger, useSidebar } from "@tensamin/ui/cmp/sidebar"; -export default function Navbar() { +export default function Navbar({ forMobile }: { forMobile: boolean }) { const navigate = useNavigate(); const { joinCall, state } = useCall(); const { conversations } = useConversation(); @@ -29,14 +29,25 @@ export default function Navbar() { const [selectOpen, setSelectOpen] = useState(false); return ( -
- +
+ {forMobile ? ( + + + + ) : ( + + )} {pathname === "/chat" && ( - - - - +
+
+ + + + +
); } diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index 7b73654..fab9543 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -8,6 +8,7 @@ import { Sidebar as SidebarRoot, SidebarContent, } from "@tensamin/ui/cmp/sidebar"; +import { isTauri } from "@tauri-apps/api/core"; /** * Renders the conversation sidebar with account summary and conversation list. @@ -23,8 +24,8 @@ export default function Sidebar() { return ( - -
+ +
} diff --git a/apps/web/src/features/conversation/modal/conversation.tsx b/apps/web/src/features/conversation/modal/conversation.tsx index ad20852..5746052 100644 --- a/apps/web/src/features/conversation/modal/conversation.tsx +++ b/apps/web/src/features/conversation/modal/conversation.tsx @@ -8,9 +8,11 @@ import { ContextMenuTrigger, } from "@tensamin/ui/cmp/context-menu"; import { useNavigate } from "@tanstack/react-router"; +import { useSidebar } from "@tensamin/ui/cmp/sidebar"; export default function ConversationModal({ userId }: { userId: number }) { const navigate = useNavigate(); + const { setOpenMobile } = useSidebar(); return ( @@ -18,7 +20,10 @@ export default function ConversationModal({ userId }: { userId: number }) { render={
navigate({ to: "/chat", search: { id: userId } })} + onClick={() => { + navigate({ to: "/chat", search: { id: userId } }); + setOpenMobile(false); + }} > } diff --git a/apps/web/src/routes/app/home.tsx b/apps/web/src/routes/app/home.tsx index fdef4b6..8c757b3 100644 --- a/apps/web/src/routes/app/home.tsx +++ b/apps/web/src/routes/app/home.tsx @@ -14,13 +14,17 @@ import { toast } from "@tensamin/shared/log"; import { useTTP } from "@tensamin/ttp/context"; import { useState } from "react"; import { Loader2 } from "lucide-react"; +import { isTauri } from "@tauri-apps/api/core"; // The page export default function Page() { return ( -
+
+
); } diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index 53555d7..2a8f129 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -7,6 +7,8 @@ import { SidebarProvider } from "@tensamin/ui/cmp/sidebar"; import { useIsMobile, cn } from "@tensamin/ui/utils"; import { motion } from "framer-motion"; +import { isTauri } from "@tauri-apps/api/core"; +import { useLocation } from "@tanstack/react-router"; /** * Executes Layout. @@ -15,6 +17,7 @@ import { motion } from "framer-motion"; */ export default function Layout(props: { children: ReactNode }) { const isMobile = useIsMobile(); + const location = useLocation(); return ( -
- {!isMobile && } +
+ {!isMobile && } + {isMobile && location.pathname === "/chat" && ( + + )} +
{props.children}
- {isMobile && } + {isMobile && location.pathname !== "/chat" && }
diff --git a/apps/web/src/routes/layout.tsx b/apps/web/src/routes/layout.tsx index b8ccff5..ea78e0c 100644 --- a/apps/web/src/routes/layout.tsx +++ b/apps/web/src/routes/layout.tsx @@ -12,6 +12,7 @@ import { TooltipProvider } from "@tensamin/ui/cmp/tooltip"; import { useStorage } from "@tensamin/storage/context"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { useIsMobile } from "@tensamin/ui/utils"; +import { isTauri } from "@tauri-apps/api/core"; /** * Executes Layout. @@ -51,7 +52,16 @@ export default function Layout(props: { children: ReactNode }) {
)}
- + diff --git a/bun.lock b/bun.lock index ec14253..e684482 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,8 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "react": "^19.2.0", + "react-dom": "^19.2.0", }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -49,6 +51,7 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", + "@tauri-apps/api": "^2", "@tensamin/call": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 698b0fd..23c7e92 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -10,8 +10,13 @@ import { useTTP } from "@tensamin/ttp/context"; import { useCrypto } from "@tensamin/crypto/context"; import { log, toast } from "@tensamin/shared/log"; -export default function InputComponent() { - const [value, setValue] = React.useState(""); +export default function InputComponent({ + value, + setValue, +}: { + value: string; + setValue: (value: string) => void; +}) { const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); const { encrypt } = useCrypto(); @@ -35,15 +40,13 @@ export default function InputComponent() { const time = Date.now(); 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"); return; } - if (!currentSharedSecret) { + if (!sharedSecret) { toast("error", "Still getting shared secret..."); return; } @@ -59,18 +62,18 @@ export default function InputComponent() { message_state: "awaiting", }); - const encryptedContext = await encrypt(currentSharedSecret, currentValue); + const encryptedContext = await encrypt(sharedSecret, currentValue); send("message_send", { height, content: encryptedContext, - receiver_id: currentUserId, + receiver_id: userId, timestamp: time, }).catch((e) => { log(0, "Chat", "red", "Failed to send message", e, { content: currentValue, encryptedContext, - receiver_id: currentUserId, + receiver_id: userId, timestamp: time, }); reference.setFailed(true); @@ -81,7 +84,7 @@ export default function InputComponent() { } return ( - + { active = false; }; - }, [decrypt, props.message.content, props.notEncrypted, sharedSecretValue]); + }, [decrypt, props.message.content, props.notEncrypted, sharedSecret]); return (
- {props.message.failed && ( + {props.message.failed && props.message.message_state === "awaiting" && (

Failed to send message

@@ -93,9 +92,18 @@ export default function Message(props: { } />
)} - {props.message.message_state === "awaiting" && - !props.message.failed && } - {isReady ? : null} + {!props.message.failed && + props.message.message_state === "awaiting" && } + {isReady ? ( + + ) : ( +
+ )}
); diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 5be3136..e977105 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -6,6 +6,7 @@ import { useState, useContext, type ReactNode, + useRef, } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRouterState } from "@tanstack/react-router"; @@ -15,6 +16,7 @@ import { useCrypto } from "@tensamin/crypto/context"; import { useUser } from "@tensamin/user/context"; import { useStorage } from "@tensamin/storage/context"; import { useTTP } from "@tensamin/ttp/context"; +import { log } from "@tensamin/shared/log"; export const context = createContext(undefined); @@ -61,28 +63,38 @@ export default function Provider(props: { children: ReactNode }) { const [liveMessagesState, setLiveMessagesState] = useState([]); const [currentSharedSecret, setCurrentSharedSecret] = useState(""); + const inputBoxRef = useRef(null); + const locationSearch = useRouterState({ select: (state) => state.location.search, }); + // User ID compare to clear shared secret const userIdValue = useMemo(() => { const rawId = (locationSearch as unknown as { id?: unknown })?.id; return Number(rawId ?? 0); }, [locationSearch]); - useEffect(() => { - const recipientId = userIdValue; + const userIdValueFromLastRender = useRef(userIdValue); - if (!recipientId) { - setCurrentSharedSecret(""); + useEffect(() => { + if (userIdValue === userIdValueFromLastRender.current) { return; } + userIdValueFromLastRender.current = userIdValue; + setCurrentSharedSecret(""); + }, [userIdValue]); + + // Load shared secret + useEffect(() => { + if (!userIdValue) return; + let active = true; void (async () => { try { - const recipientData = await get(recipientId); + const recipientData = await get(userIdValue); const ownId = await load("user_id"); const privateKey = await load("private_key"); 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.timestamp) ) { + log( + 3, + "chat", + "yellow", + "Cancel message state update due to invalid data", + ); return; } 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; } @@ -246,28 +274,21 @@ export default function Provider(props: { children: ReactNode }) { }); }, [subscribePush, userIdValue]); - const value = useMemo( - () => ({ - getMessages: customGetMessages, - liveMessages: () => liveMessagesState, - addLiveMessage, - clearLiveMessages, - sharedSecret: () => currentSharedSecret, - userId: () => userIdValue, - }), - [ - addLiveMessage, - clearLiveMessages, - currentSharedSecret, - customGetMessages, - liveMessagesState, - userIdValue, - ], - ); - return ( - {props.children} + liveMessagesState, + addLiveMessage, + clearLiveMessages, + sharedSecret: currentSharedSecret, + userId: userIdValue, + inputBoxRef, + }} + > + {props.children} + ); } @@ -279,8 +300,9 @@ type contextType = { setFailed: (failed: boolean) => void; }; clearLiveMessages: () => void; - sharedSecret: () => string; - userId: () => number; + sharedSecret: string; + userId: number; + inputBoxRef: React.RefObject; }; /** diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 23b3995..d4da8a1 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -7,6 +7,7 @@ import InputComponent from "./components/input"; import Message from "./components/message"; import { PAGE_SIZE } from "./values"; +import { useLayoutEffect } from "react"; /** * Renders the chat screen with virtualized history and live message updates. @@ -15,6 +16,7 @@ import { PAGE_SIZE } from "./values"; export default function Screen() { const { getMessages, liveMessages, clearLiveMessages, userId } = useChat(); + const inputBoxRef = React.useRef(null); const scrollRef = React.useRef(null); const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] = @@ -25,11 +27,10 @@ export default function Screen() { scrollTop: number; } | null>(null); - const chatUserId = userId(); - const hasValidChatUser = Number.isSafeInteger(chatUserId) && chatUserId > 0; + const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const messagesQuery = useInfiniteQuery({ - queryKey: ["chat-messages", String(chatUserId)], + queryKey: ["chat-messages", String(userId)], initialPageParam: 0, queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)), enabled: hasValidChatUser, @@ -52,7 +53,7 @@ export default function Screen() { setHasScrolledToBottomInitially(false); setLastLiveMessageCount(0); setPrependAnchor(null); - }, [chatUserId, clearLiveMessages]); + }, [userId, clearLiveMessages]); const liveMessagesSnapshot = liveMessages(); @@ -152,6 +153,28 @@ export default function Screen() { void 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) { return (
@@ -161,11 +184,11 @@ export default function Screen() { } return ( -
+
- +
+ +
); }