diff --git a/apps/web/package.json b/apps/web/package.json index 0bf0f79..af7c2a8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." }, "dependencies": { + "@tensamin/call": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", "@tensamin/ttp": "workspace:*", diff --git a/apps/web/src/components/modals/basic.tsx b/apps/web/src/components/modals/basic.tsx index 56062a4..4a31f7f 100644 --- a/apps/web/src/components/modals/basic.tsx +++ b/apps/web/src/components/modals/basic.tsx @@ -6,7 +6,7 @@ import { Skeleton } from "@tensamin/ui/cmp/skeleton"; export function Basic(props: { user: User }) { return ( - + @@ -21,5 +21,5 @@ export function Basic(props: { user: User }) { } export function Loading() { - return ; + return ; } diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 219a713..5b7b23c 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -1,64 +1,95 @@ import { Button } from "@tensamin/ui/cmp/button"; -import { House } from "lucide-react"; -import { useNavigate, useRouterState } from "@tanstack/react-router"; -import * as React from "react"; -import { useUser, type User } from "@tensamin/user/context"; +import { House, Phone } from "lucide-react"; +import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; +import { useCall } from "@tensamin/call/context"; +import Wrapper from "@tensamin/user/wrapper"; +import { Skeleton } from "@tensamin/ui/cmp/skeleton"; +import { useConversation } from "@/features/conversation/context"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, +} from "@tensamin/ui/cmp/select"; +import { displayCallId } from "@tensamin/call/utils"; -/** - * Navigates to the home route when the navbar home button is clicked. - * @param navigate Router navigate function from TanStack Router. - * @returns Void. - */ -function handleHomeButtonClick(navigate: ReturnType): void { - void navigate({ to: "/" }); -} - -/** - * Renders the top navigation bar and currently selected conversation user. - * @returns Navbar JSX element. - */ export default function Navbar() { const navigate = useNavigate(); - const { get } = useUser(); - const search = useRouterState({ select: (state) => state.location.search }); + const { joinCall, state } = useCall(); + const { conversations } = useConversation(); - /** - * Delegates navbar home button click to navigation helper. - * @returns Void. - */ - const onHomeButtonClick = React.useCallback(() => { - handleHomeButtonClick(navigate); - }, [navigate]); + const { pathname } = useLocation(); + const { id } = useSearch({ strict: false }); - const [user, setUser] = React.useState(null); - - const currentId = React.useMemo( - () => Number((search as Record).id ?? Number.NaN), - [search], - ); - - React.useEffect(() => { - if (Number.isNaN(currentId)) { - setUser(null); - return; - } - - get(currentId) - .then(setUser) - .catch(() => setUser(null)); - }, [currentId, get]); + const currentCalls = id ? conversations[id]?.calls || [] : []; return (
-

{user?.display}

+ {pathname === "/app/chat" && ( + ( +

{user?.display}

+ )} + loading={} + /> + )}
+ {pathname === "/app/chat" && id && ( + <> + {currentCalls.length > 0 ? ( + + ) : currentCalls.length === 1 ? ( + + ) : ( + + )} + + )}
); } diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index 511b129..c137354 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -18,11 +18,13 @@ export default function Sidebar() { return (
- } - userId={userId} - component={(user) => } - /> +
+ } + userId={userId} + component={(user) => } + /> +
diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index a7a14bc..8f50163 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -15,11 +15,18 @@ import AppLayout from "./routes/app/layout"; import Home from "@/routes/app/home"; import ChatScreen from "@tensamin/chat/screen"; -import ChatContext from "@tensamin/chat/context"; +import CallScreen from "@tensamin/call/screen"; import Login from "@/routes/screens/login"; import Signup from "@/routes/screens/signup"; +import ConversationContext from "@/features/conversation/context"; +import ChatContext from "@tensamin/chat/context"; +import CallContext from "@tensamin/call/context"; +import SocketContext from "@tensamin/ttp/context"; +import UserContext from "@tensamin/user/context"; + import { ThemeProvider } from "@tensamin/ui/theme"; +import z from "zod"; const wrapper = document.getElementById("root"); @@ -33,11 +40,6 @@ window.setLogLevelToMax = () => { location.reload(); }; -/** - * Executes RootShell. - * @param none This function has no parameters. - * @returns unknown. - */ function RootShell() { return ( @@ -50,24 +52,22 @@ function RootShell() { ); } -/** - * Executes AppShell. - * @param none This function has no parameters. - * @returns unknown. - */ function AppShell() { return ( - - - + + + + + + + + + + + ); } -/** - * Executes Chat. - * @param none This function has no parameters. - * @returns unknown. - */ function Chat() { return ( @@ -77,6 +77,7 @@ function Chat() { } const rootRoute = createRootRoute({ + component: RootShell, }); @@ -96,6 +97,15 @@ const chatRoute = createRoute({ getParentRoute: () => appRoute, path: "chat", component: Chat, + validateSearch: z.object({ + id: z.number().optional(), + }) +}); + +const callRoute = createRoute({ + getParentRoute: () => appRoute, + path: "call", + component: CallScreen, }); const loginRoute = createRoute({ @@ -111,7 +121,7 @@ const signupRoute = createRoute({ }); const routeTree = rootRoute.addChildren([ - appRoute.addChildren([homeRoute, chatRoute]), + appRoute.addChildren([homeRoute, chatRoute, callRoute]), loginRoute, signupRoute, ]); diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index e7fa7c0..470568c 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -1,10 +1,7 @@ -import Socket from "@tensamin/ttp/context"; -import User from "@tensamin/user/context"; import { useEffect, useState, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import Sidebar from "@/components/sidebar"; -import Conversation from "@/features/conversation/context"; import Navbar from "@/components/navbar"; import { useStorage } from "@tensamin/storage/context"; @@ -49,20 +46,14 @@ export default function Layout(props: { children: ReactNode }) { } return ( - - - -
- -
- -
- {props.children} -
-
-
-
-
-
+
+ +
+ +
+ {props.children} +
+
+
); } diff --git a/bun.lock b/bun.lock index febc1a5..91dea86 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", + "@tensamin/call": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", "@tensamin/shared": "workspace:*", @@ -72,6 +73,26 @@ "vite": "^7.3.1", }, }, + "packages/call": { + "name": "@tensamin/call", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "@tanstack/react-router": "^1.0.0", + "@tanstack/react-virtual": "^3.0.0", + "@tensamin/crypto": "workspace:*", + "@tensamin/markdown": "workspace:*", + "@tensamin/shared": "workspace:*", + "@tensamin/storage": "workspace:*", + "@tensamin/ttp": "workspace:*", + "@tensamin/ui": "workspace:*", + "@tensamin/user": "workspace:*", + "lucide-react": "^0.564.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.3.6", + }, + }, "packages/chat": { "name": "@tensamin/chat", "version": "0.0.0", @@ -590,6 +611,8 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="], + "@tensamin/call": ["@tensamin/call@workspace:packages/call"], + "@tensamin/chat": ["@tensamin/chat@workspace:packages/chat"], "@tensamin/crypto": ["@tensamin/crypto@workspace:packages/crypto"], diff --git a/packages/call/package.json b/packages/call/package.json new file mode 100644 index 0000000..d999407 --- /dev/null +++ b/packages/call/package.json @@ -0,0 +1,32 @@ +{ + "name": "@tensamin/call", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + "./context": "./src/context.tsx", + "./screen": "./src/screen.tsx", + "./utils": "./src/utils.ts" + }, + "scripts": { + "format": "bunx prettier --write .", + "lint": "eslint src", + "build": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "@tanstack/react-router": "^1.0.0", + "@tanstack/react-virtual": "^3.0.0", + "@tensamin/crypto": "workspace:*", + "@tensamin/ttp": "workspace:*", + "@tensamin/storage": "workspace:*", + "@tensamin/user": "workspace:*", + "@tensamin/markdown": "workspace:*", + "@tensamin/shared": "workspace:*", + "@tensamin/ui": "workspace:*", + "lucide-react": "^0.564.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.3.6" + } +} diff --git a/packages/call/src/context.tsx b/packages/call/src/context.tsx new file mode 100644 index 0000000..0f8c3f7 --- /dev/null +++ b/packages/call/src/context.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import { log } from "@tensamin/shared/log"; +import { useNavigate } from "@tanstack/react-router"; + +export const context = createContext(undefined); + +export default function Provider(props: { children: React.ReactNode }) { + const navigate = useNavigate(); + + const [state, setState] = useState<"closed" | "connecting" | "open">( + "closed", + ); + + const [callId, setCallId] = useState(null); + const [callSecret, setCallSecret] = useState(null); + + function connect(callId: string, callSecret: string) { + setState("connecting"); + setCallId(callId); + setCallSecret(callSecret); + log(2, "Call", "purple", "Connecting to call", { callId, callSecret }); + } + + // Master reset function + function disconnect() { + setState("closed"); + setCallId(null); + setCallSecret(null); + } + + // Utils + function joinCall(userId: number, callId?: string) { + // get/generate e2ee secret + // go into convs and find call id + // generate shared secret and decrypt enc call secret + + // check if user is already in call + + // connect to call + connect("", ""); + + // navigate to call page + navigate({ to: "/call", search: { userId: userId, callId: callId } }); + } + + // Event listener for incoming calls + useEffect(() => {}, []); + + return ( + + {props.children} + + ); +} + +type contextType = { + state: "closed" | "connecting" | "open"; + connect: (callId: string, callSecret: string) => void; + disconnect: () => void; + joinCall: (userId: number, callId?: string) => void; +}; + +export function useCall(): contextType { + const ctx = useContext(context); + if (!ctx) { + throw new Error("useCall must be used within a CallProvider"); + } + return ctx; +} diff --git a/packages/call/src/screen.tsx b/packages/call/src/screen.tsx new file mode 100644 index 0000000..88679f6 --- /dev/null +++ b/packages/call/src/screen.tsx @@ -0,0 +1,3 @@ +export default function Screen() { + return
; +} diff --git a/packages/call/src/utils.ts b/packages/call/src/utils.ts new file mode 100644 index 0000000..501fe76 --- /dev/null +++ b/packages/call/src/utils.ts @@ -0,0 +1,3 @@ +export function displayCallId(callId: string) { + return callId.slice(0, 4) + "..." + callId.slice(-4); +} diff --git a/packages/call/src/values.ts b/packages/call/src/values.ts new file mode 100644 index 0000000..692737b --- /dev/null +++ b/packages/call/src/values.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { socket } from "@tensamin/shared/data"; + +export type RawMessages = z.infer< + typeof socket.messages_get.response +>["messages"]; + +export type RawMessage = RawMessages[number]; + +export type LiveMessage = RawMessage & { + failed?: boolean; + localId: string; +}; diff --git a/packages/call/tsconfig.json b/packages/call/tsconfig.json new file mode 100644 index 0000000..9714625 --- /dev/null +++ b/packages/call/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 013c69c..9634269 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -21,6 +21,18 @@ const message = z.object({ display: z.boolean().optional(), }); +export const failedUser = { + display: "Failed to load user", + iota_id: 0, + omikron_connections: [], + online_status: "user_borked", + public_key: "", + sub_end: 0, + sub_level: 0, + user_id: 0, + username: "unknown", +} as z.infer; + // Socket export const socket = { identification: { diff --git a/packages/shared/src/log.tsx b/packages/shared/src/log.tsx index c8d4a3c..e202b17 100644 --- a/packages/shared/src/log.tsx +++ b/packages/shared/src/log.tsx @@ -2,12 +2,11 @@ import { toast as sonnerToast } from "sonner"; import { Ban, Check, Info, TriangleAlert } from "lucide-react"; /** - * Executes log. - * @param logLevel Parameter logLevel. - * @param logger Parameter logger. - * @param color Parameter color. - * @param args Parameter args. - * @returns unknown. + * Log Levels + * - 0: Always logged + * - 1: Errors and warnings + * - 2: Useful information + * - 3: Debug messages */ export function log( logLevel: number, diff --git a/packages/ttp/src/codec.ts b/packages/ttp/src/codec.ts index 0d78e56..0d00bfb 100644 --- a/packages/ttp/src/codec.ts +++ b/packages/ttp/src/codec.ts @@ -196,6 +196,7 @@ const DATA_TYPES = [ "timeout", "has_admin", "last_message_at", + "height" ] as const; const COMMUNICATION_TYPE_BY_NAME = createIndexMap(COMMUNICATION_TYPES); @@ -224,6 +225,7 @@ registerDataKinds("number", [ "sub_level", "sub_end", "last_message_at", + "height" ]); registerDataKinds("string", [ @@ -276,7 +278,7 @@ registerDataKinds({ array: "number" }, [ "last_ping", "ping_iota", "get_time", - "omikron_connections", + "omikron_connections" ]); registerDataKinds("container", [ diff --git a/packages/ui/src/cmp/select.tsx b/packages/ui/src/cmp/select.tsx new file mode 100644 index 0000000..10c1601 --- /dev/null +++ b/packages/ui/src/cmp/select.tsx @@ -0,0 +1,199 @@ +import * as React from "react" +import { Select as SelectPrimitive } from "@base-ui/react/select" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +const Select = SelectPrimitive.Root + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + } + /> + + ) +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/packages/user/src/wrapper.tsx b/packages/user/src/wrapper.tsx index fedd09d..7533830 100644 --- a/packages/user/src/wrapper.tsx +++ b/packages/user/src/wrapper.tsx @@ -1,25 +1,26 @@ -import * as React from "react"; +import { useEffect, useState } from "react"; import { useUser, type User } from "./context"; -/** - * Executes Wrapper. - * @param props Parameter props. - * @returns unknown. - */ +import { failedUser } from "@tensamin/shared/data"; + +// Wrapper function to pass user data to some component export default function Wrapper(props: { userId?: number; loading: React.ReactNode; component: (user: User) => React.ReactNode; }) { const { get } = useUser(); - const [user, setUser] = React.useState(null); + const [user, setUser] = useState(null); - React.useEffect(() => { - if (!props.userId) return; + useEffect(() => { + if (!props.userId) { + setUser(failedUser); + return; + } let active = true; - void get(props.userId) + get(props.userId) .then((value) => { if (active) { setUser(value); @@ -27,7 +28,7 @@ export default function Wrapper(props: { }) .catch(() => { if (active) { - setUser(null); + setUser(failedUser); } }); diff --git a/todo.md b/todo.md index ddd7b9b..8dfd2e3 100644 --- a/todo.md +++ b/todo.md @@ -1,3 +1,4 @@ - Calculate message height and pass to virtualizer (-> packages/chat/src/components/input.tsx) - Split ttp codec into separate file - Add "Rename Conversations to Friends" option in settings +- Handle live messages in notification context