diff --git a/apps/web/package.json b/apps/web/package.json index 294e69b..0bf0f79 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "format": "bunx prettier --write .", "lint": "eslint src", "dev": "vite --port 3000", - "build": "tsc -b && vite build", + "test": "bun test --pass-with-no-tests", + "build": "bun run test && tsc -b && vite build", "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." }, "dependencies": { diff --git a/apps/web/src/components/modals/basic.tsx b/apps/web/src/components/modals/basic.tsx index 5aff126..b3c8025 100644 --- a/apps/web/src/components/modals/basic.tsx +++ b/apps/web/src/components/modals/basic.tsx @@ -2,10 +2,16 @@ import type { User } from "@tensamin/user/context"; import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui/cmp/avatar"; import { reduceDisplay } from "./utils"; import { Card, CardHeader } from "@tensamin/ui/cmp/card"; +import { Skeleton } from "@tensamin/ui/cmp/skeleton"; -export default function Basic(props: { user: User }) { +/** + * Executes Basic. + * @param props Parameter props. + * @returns unknown. + */ +export function Basic(props: { user: User }) { return ( - + @@ -18,3 +24,11 @@ export default function Basic(props: { user: User }) { ); } + +/** + * Executes Loading. + * @returns unknown. + */ +export function Loading() { + return ; +} diff --git a/apps/web/src/components/modals/utils.ts b/apps/web/src/components/modals/utils.ts index 9455031..248dcd5 100644 --- a/apps/web/src/components/modals/utils.ts +++ b/apps/web/src/components/modals/utils.ts @@ -1,3 +1,8 @@ +/** + * Executes reduceDisplay. + * @param display Parameter display. + * @returns unknown. + */ export function reduceDisplay(display: string) { const words = display.split(" "); if (words.length === 1) { diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 006a570..219a713 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -4,11 +4,32 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; import * as React from "react"; import { useUser, type User } from "@tensamin/user/context"; +/** + * 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 }); + /** + * Delegates navbar home button click to navigation helper. + * @returns Void. + */ + const onHomeButtonClick = React.useCallback(() => { + handleHomeButtonClick(navigate); + }, [navigate]); + const [user, setUser] = React.useState(null); const currentId = React.useMemo( @@ -30,13 +51,11 @@ export default function Navbar() { return (

{user?.display}

diff --git a/apps/web/src/components/screens/login/form.tsx b/apps/web/src/components/screens/login/form.tsx index b6952e7..3bb2c5a 100644 --- a/apps/web/src/components/screens/login/form.tsx +++ b/apps/web/src/components/screens/login/form.tsx @@ -32,11 +32,118 @@ const formSchema = z.object({ private_key: z.string().min(1).max(92), }); +/** + * Parses a .tu file payload into credentials. + * @param rawFileContent UTF-8 file content from an uploaded .tu file. + * @returns Parsed user id and private key credentials. + */ +function parseTuFileContent(rawFileContent: string): { + userId: number; + privateKey: string; +} { + if (rawFileContent.length !== 92 || !rawFileContent.includes("::")) { + throw new Error("Invalid file"); + } + + const [userIdString, privateKey] = rawFileContent.split("::"); + const userId = Number(userIdString); + if (!userId || !privateKey) { + throw new Error("Invalid file"); + } + + return { userId, privateKey }; +} + +/** + * Renders the login form for file upload and manual credential login. + * @returns Login form JSX. + */ export default function Form() { const uploadRef = React.useRef(null); const { save } = useStorage(); const navigate = useNavigate(); + /** + * Opens the hidden file input when the upload tile is clicked. + * @returns Void. + */ + const handleUploadTileClick = React.useCallback((): void => { + uploadRef.current?.click(); + }, []); + + /** + * Handles uploaded .tu files and stores resolved credentials. + * @param event Change event from the hidden file input. + * @returns Promise that resolves when processing has finished. + */ + const handleFileInputChange = React.useCallback( + async (event: React.ChangeEvent): Promise => { + try { + const file = event.currentTarget.files?.[0]; + if (!file) { + throw new Error("No file selected"); + } + + const raw = await file.text(); + const parsed = parseTuFileContent(raw); + + await save("user_id", parsed.userId); + await save("private_key", parsed.privateKey); + + void navigate({ to: "/" }); + } catch (error) { + log(0, "Login", "red", error); + toast("error", "Failed to load file"); + } + }, + [navigate, save], + ); + + /** + * Handles username and private key login submission. + * @param event Form submit event. + * @returns Promise that resolves after login processing. + */ + const handleCredentialsSubmit = React.useCallback( + async (event: React.FormEvent): Promise => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const rawData = Object.fromEntries(formData); + const inputParse = formSchema.safeParse(rawData); + + if (!inputParse.success) { + toast("error", "Please enter valid data"); + return; + } + + try { + const response = await fetch( + `https://omega.tensamin.net/api/get/id/${inputParse.data.username}`, + ); + const data = await response.json(); + const parse = fetchedUser.safeParse(data); + + if (!parse.success) { + log(0, "Login", "red", "Invalid response from server"); + toast("error", "Invalid response from server"); + return; + } + + const user = parse.data; + + await save("user_id", user.data.user_id); + await save("private_key", inputParse.data.private_key); + + void navigate({ to: "/" }); + } catch (error) { + log(0, "Login", "red", error); + toast("error", "Failed to fetch user data"); + } + }, + [navigate, save], + ); + return (
@@ -45,35 +152,13 @@ export default function Form() {
uploadRef.current?.click()} + onClick={handleUploadTileClick} className="cursor-pointer w-60 aspect-square mb-17 bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-dotted border-input/75 border-3 flex items-center justify-center rounded-lg" >
{ - try { - const file = e.currentTarget.files?.[0]; - if (file) { - const raw = await file.text(); - if (raw.length !== 92) throw new Error("Invalid file"); - if (!raw.includes("::")) throw new Error("Invalid file"); - const [userIdString, privateKey] = raw.split("::"); - const userId = Number(userIdString); - if (!userId || !privateKey) throw new Error("Invalid file"); - - save("user_id", userId); - save("private_key", privateKey); - - void navigate({ to: "/" }); - } else { - throw new Error("No file selected"); - } - } catch (err) { - log(0, "Login", "red", err); - toast("error", "Failed to load file"); - } - }} + onChange={handleFileInputChange} type="file" ref={uploadRef} className="hidden" @@ -87,44 +172,7 @@ export default function Form() {
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const rawData = Object.fromEntries(formData); - const inputParse = formSchema.safeParse(rawData); - - if (!inputParse.success) { - toast("error", "Please enter valid data"); - return; - } - - const response = await fetch( - "https://omega.tensamin.net/api/get/id/" + - inputParse.data.username, - ); - - response - .json() - .then((data) => { - const parse = fetchedUser.safeParse(data); - - if (parse.success) { - const user = parse.data; - - save("user_id", user.data.user_id); - save("private_key", inputParse.data.private_key); - - void navigate({ to: "/" }); - } else { - log(0, "Login", "red", "Invalid response from server"); - toast("error", "Invalid response from server"); - } - }) - .catch((err) => { - log(0, "Login", "red", err); - toast("error", "Failed to fetch user data"); - }); - }} + onSubmit={handleCredentialsSubmit} >
diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index 9c4f4f4..b48cffb 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -1,11 +1,33 @@ import { useStorage } from "@tensamin/storage/context"; import Wrapper from "@tensamin/user/wrapper"; +import { type User } from "@tensamin/user/context"; import * as React from "react"; -import Basic from "./modals/basic"; +import { Basic, Loading } from "./modals/basic"; import List from "@/features/conversation/list/body"; +/** + * Renders sidebar user summary content for the current user. + * @param user Loaded user data. + * @returns Sidebar user card JSX. + */ +function renderSidebarUser(user: User): React.ReactNode { + return ; +} + +/** + * Renders sidebar user summary content skeleton while loading user data. + * @returns Sidebar user card skeleton JSX. + */ +function renderSidebarUserLoading(): React.ReactNode { + return ; +} + +/** + * Renders the conversation sidebar with account summary and conversation list. + * @returns Sidebar JSX. + */ export default function Sidebar() { - const [userId, setUserId] = React.useState(0); + const [userId, setUserId] = React.useState(undefined); const { load } = useStorage(); React.useEffect(() => { @@ -14,9 +36,11 @@ export default function Sidebar() { return (
- {userId !== 0 && ( - } /> - )} +
diff --git a/apps/web/src/features/conversation/context.tsx b/apps/web/src/features/conversation/context.tsx index 7c34f61..0198115 100644 --- a/apps/web/src/features/conversation/context.tsx +++ b/apps/web/src/features/conversation/context.tsx @@ -16,6 +16,11 @@ const ConversationContext = React.createContext( undefined, ); +/** + * Executes ConversationProvider. + * @param props Parameter props. + * @returns unknown. + */ export default function ConversationProvider(props: { children: React.ReactNode; }) { @@ -64,6 +69,11 @@ export default function ConversationProvider(props: { ); } +/** + * Executes useConversation. + * @param none This function has no parameters. + * @returns contextValue. + */ export function useConversation(): contextValue { const context = React.useContext(ConversationContext); if (!context) { diff --git a/apps/web/src/features/conversation/list/body.tsx b/apps/web/src/features/conversation/list/body.tsx index 8a4d4ab..28b35d0 100644 --- a/apps/web/src/features/conversation/list/body.tsx +++ b/apps/web/src/features/conversation/list/body.tsx @@ -6,6 +6,11 @@ import Switch from "./switch"; import ConversationModal from "../modal/conversation"; import CommunityModal from "../modal/community"; +/** + * Executes List. + * @param none This function has no parameters. + * @returns unknown. + */ export default function List() { const [category, setCategory] = React.useState< "conversations" | "communities" diff --git a/apps/web/src/features/conversation/list/switch.tsx b/apps/web/src/features/conversation/list/switch.tsx index cdf180d..025565f 100644 --- a/apps/web/src/features/conversation/list/switch.tsx +++ b/apps/web/src/features/conversation/list/switch.tsx @@ -2,6 +2,11 @@ import * as React from "react"; type Category = "conversations" | "communities"; +/** + * Executes Switch. + * @param props Parameter props. + * @returns unknown. + */ export default function Switch(props: { category: Category; setCategory: (category: Category) => void; @@ -28,6 +33,11 @@ export default function Switch(props: { updateIndicator(); }, [updateIndicator]); + /** + * Executes toggleCategory. + * @param none This function has no parameters. + * @returns unknown. + */ function toggleCategory() { props.setCategory( props.category === "conversations" ? "communities" : "conversations", diff --git a/apps/web/src/features/conversation/modal/community.tsx b/apps/web/src/features/conversation/modal/community.tsx index 2228838..a7286e0 100644 --- a/apps/web/src/features/conversation/modal/community.tsx +++ b/apps/web/src/features/conversation/modal/community.tsx @@ -1,5 +1,10 @@ import type { Community } from "@tensamin/shared/features/conversation/schema"; +/** + * Executes CommunityModal. + * @param props Parameter props. + * @returns unknown. + */ export default function CommunityModal(props: { community: Community }) { return
{props.community.community_title}
; } diff --git a/apps/web/src/features/conversation/modal/conversation.tsx b/apps/web/src/features/conversation/modal/conversation.tsx index 2ce8251..36a0fd6 100644 --- a/apps/web/src/features/conversation/modal/conversation.tsx +++ b/apps/web/src/features/conversation/modal/conversation.tsx @@ -1,4 +1,4 @@ -import Basic from "@/components/modals/basic"; +import { Basic, Loading } from "@/components/modals/basic"; import Wrapper from "@tensamin/user/wrapper"; import { ContextMenu, @@ -8,22 +8,52 @@ import { ContextMenuTrigger, } from "@tensamin/ui/cmp/context-menu"; import { useNavigate } from "@tanstack/react-router"; +import { type User } from "@tensamin/user/context"; +/** + * Renders the conversation modal user preview card. + * @param user Loaded user data. + * @returns Conversation preview card JSX. + */ +function renderConversationUser(user: User) { + return ; +} + +/** + * Renders the conversation modal user preview card skeleton while loading user data. + * @returns Conversation preview card skeleton JSX. + */ +function renderConversationUserLoading() { + return ; +} + +/** + * Renders a conversation context-menu entry for a specific user. + * @param props Component props with selected user id. + * @returns Conversation modal trigger and menu JSX. + */ export default function ConversationModal(props: { userId: number }) { const navigate = useNavigate(); + /** + * Navigates to the selected conversation in chat view. + * @returns Void. + */ + const onConversationClick = () => { + void navigate({ to: "/chat", search: { id: props.userId } }); + }; + return (
{ - void navigate({ to: "/chat", search: { id: props.userId } }); - }} + onClick={onConversationClick} > } + component={renderConversationUser} />
diff --git a/apps/web/src/features/legal/screen.tsx b/apps/web/src/features/legal/screen.tsx index d351fc1..4551bce 100644 --- a/apps/web/src/features/legal/screen.tsx +++ b/apps/web/src/features/legal/screen.tsx @@ -11,6 +11,60 @@ import { log } from "@tensamin/shared/log"; import Link from "@tensamin/ui/link"; import { Label } from "@tensamin/ui/cmp/label"; +type SaveFn = ReturnType["save"]; + +/** + * Persists accepted legal documents and marks the first onboarding step complete. + * @param save Storage save function. + * @param currentDocs Current legal documents fetched from the server. + * @param setPPandToSDone State setter for legal acceptance completion. + * @returns Promise that resolves when persistence is complete. + */ +async function persistAcceptedDocs( + save: SaveFn, + currentDocs: z.infer, + setPPandToSDone: React.Dispatch>, +): Promise { + await save("accepted_privacy_policy", true); + await save("accepted_terms_of_service", true); + await save("ppandtos_done", true); + await save("legal_docs", currentDocs); + setPPandToSDone(true); +} + +/** + * Persists analytics preference toggles and marks analytics onboarding complete. + * @param save Storage save function. + * @param crashReports Whether crash reports are enabled. + * @param usageData Whether usage data is enabled. + * @param setCrashReports State setter for crash reports. + * @param setUsageData State setter for usage data. + * @param setDoneWithAnalytics State setter for analytics completion. + * @returns Promise that resolves when persistence is complete. + */ +async function persistAnalyticsPreferences( + save: SaveFn, + crashReports: boolean, + usageData: boolean, + setCrashReports: React.Dispatch>, + setUsageData: React.Dispatch>, + setDoneWithAnalytics: React.Dispatch>, +): Promise { + await save("analytics_crash_reports", crashReports); + setCrashReports(crashReports); + + await save("analytics_usage_data", usageData); + setUsageData(usageData); + + await save("analytics_done", true); + setDoneWithAnalytics(true); +} + +/** + * Gates the application behind legal and analytics consent checks. + * @param props Component props containing children to render after consent. + * @returns Legal onboarding or wrapped children JSX. + */ export default function Screen(props: { children: React.ReactNode }) { const { load, save } = useStorage(); @@ -31,6 +85,37 @@ export default function Screen(props: { children: React.ReactNode }) { const [crashReports, setCrashReports] = React.useState(false); const [usageData, setUsageData] = React.useState(false); + /** + * Handles continue action for privacy policy and terms acceptance. + * @returns Void. + */ + const handleContinueLegal = React.useCallback((): void => { + const currentDocs = remoteDocs; + if (!currentDocs) { + return; + } + + void persistAcceptedDocs(save, currentDocs, setPPandToSDone); + }, [remoteDocs, save]); + + /** + * Handles continue action for analytics preferences. + * @returns Void. + */ + const handleContinueAnalytics = React.useCallback((): void => { + const currentCrashReports = crashReports; + const currentUsageData = usageData; + + void persistAnalyticsPreferences( + save, + currentCrashReports, + currentUsageData, + setCrashReports, + setUsageData, + setDoneWithAnalytics, + ); + }, [crashReports, save, usageData]); + React.useEffect(() => { let active = true; @@ -178,20 +263,7 @@ export default function Screen(props: { children: React.ReactNode }) {
{ - const currentDocs = remoteDocs; - if (!currentDocs) { - return; - } - - void (async () => { - await save("accepted_privacy_policy", true); - await save("accepted_terms_of_service", true); - await save("ppandtos_done", true); - await save("legal_docs", currentDocs); - setPPandToSDone(true); - })(); - }} + onClick={handleContinueLegal} /> ) : ( @@ -211,24 +283,7 @@ export default function Screen(props: { children: React.ReactNode }) { />
- { - const currentCrashReports = crashReports; - const currentUsageData = usageData; - - void save("analytics_crash_reports", currentCrashReports).then( - () => { - setCrashReports(currentCrashReports); - }, - ); - void save("analytics_usage_data", currentUsageData).then(() => { - setUsageData(currentUsageData); - }); - void save("analytics_done", true).then(() => { - setDoneWithAnalytics(true); - }); - }} - /> + )}
@@ -236,6 +291,11 @@ export default function Screen(props: { children: React.ReactNode }) { ); } +/** + * Renders a large continue button used by legal and analytics steps. + * @param props Button props with click callback and disabled state. + * @returns Continue button JSX. + */ function ContinueButton(props: { onClick: () => void; disabled?: boolean }) { return (
@@ -251,6 +311,11 @@ function ContinueButton(props: { onClick: () => void; disabled?: boolean }) { ); } +/** + * Renders a larger checkbox row for onboarding preferences. + * @param props Checkbox label, current value, and change callback. + * @returns Checkbox row JSX. + */ export function BigCheckbox(props: { label: string; checked: boolean; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index a15b308..a7a14bc 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -33,6 +33,11 @@ window.setLogLevelToMax = () => { location.reload(); }; +/** + * Executes RootShell. + * @param none This function has no parameters. + * @returns unknown. + */ function RootShell() { return ( @@ -45,6 +50,11 @@ function RootShell() { ); } +/** + * Executes AppShell. + * @param none This function has no parameters. + * @returns unknown. + */ function AppShell() { return ( @@ -53,6 +63,11 @@ function AppShell() { ); } +/** + * Executes Chat. + * @param none This function has no parameters. + * @returns unknown. + */ function Chat() { return ( diff --git a/apps/web/src/routes/404.tsx b/apps/web/src/routes/404.tsx index 7b0be11..12eaa72 100644 --- a/apps/web/src/routes/404.tsx +++ b/apps/web/src/routes/404.tsx @@ -1,3 +1,8 @@ +/** + * Executes Page. + * @param none This function has no parameters. + * @returns unknown. + */ export default function Page() { return (
diff --git a/apps/web/src/routes/app/home.tsx b/apps/web/src/routes/app/home.tsx index 0f7fc06..59f48c2 100644 --- a/apps/web/src/routes/app/home.tsx +++ b/apps/web/src/routes/app/home.tsx @@ -1,3 +1,8 @@ +/** + * Executes Page. + * @param none This function has no parameters. + * @returns unknown. + */ export default function Page() { return
Home
; } diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index abf43dd..f637b46 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -6,6 +6,11 @@ import Sidebar from "@/components/sidebar"; import Conversation from "@/features/conversation/context"; import Navbar from "@/components/navbar"; +/** + * Executes Layout. + * @param props Parameter props. + * @returns unknown. + */ export default function Layout(props: { children: ReactNode }) { return ( diff --git a/apps/web/src/routes/layout.tsx b/apps/web/src/routes/layout.tsx index 60de442..a5ed6ad 100644 --- a/apps/web/src/routes/layout.tsx +++ b/apps/web/src/routes/layout.tsx @@ -7,6 +7,11 @@ import LegalWrapper from "@/features/legal/screen"; import { Toaster } from "@tensamin/ui/cmp/sonner"; +/** + * Executes Layout. + * @param props Parameter props. + * @returns unknown. + */ export default function Layout(props: { children: ReactNode }) { return ( <> diff --git a/apps/web/src/routes/screens/login.tsx b/apps/web/src/routes/screens/login.tsx index 95773ba..65a6659 100644 --- a/apps/web/src/routes/screens/login.tsx +++ b/apps/web/src/routes/screens/login.tsx @@ -2,6 +2,11 @@ import Form from "@/components/screens/login/form"; import { Button } from "@tensamin/ui/cmp/button"; import { Link } from "@tanstack/react-router"; +/** + * Executes Page. + * @param none This function has no parameters. + * @returns unknown. + */ export default function Page() { return (
diff --git a/apps/web/src/routes/screens/signup.tsx b/apps/web/src/routes/screens/signup.tsx index 7eff9c9..e3697ad 100644 --- a/apps/web/src/routes/screens/signup.tsx +++ b/apps/web/src/routes/screens/signup.tsx @@ -1,3 +1,8 @@ +/** + * Executes Page. + * @param none This function has no parameters. + * @returns unknown. + */ export default function Page() { return
Signup Page
; } diff --git a/packages/chat/src/behaviour/community.ts b/packages/chat/src/behaviour/community.ts index 1340103..8009641 100644 --- a/packages/chat/src/behaviour/community.ts +++ b/packages/chat/src/behaviour/community.ts @@ -1,3 +1,8 @@ +/** + * Executes getMessages. + * @param none This function has no parameters. + * @returns unknown. + */ export function getMessages() { return 0; } diff --git a/packages/chat/src/behaviour/conversation.ts b/packages/chat/src/behaviour/conversation.ts index 6c306e8..d50223f 100644 --- a/packages/chat/src/behaviour/conversation.ts +++ b/packages/chat/src/behaviour/conversation.ts @@ -2,6 +2,14 @@ import type { BoundSendFn } from "@tensamin/ttp/core"; import type { Socket } from "@tensamin/shared/data"; import type { RawMessages } from "../values"; +/** + * Executes getMessages. + * @param send Parameter send. + * @param amount Parameter amount. + * @param offset Parameter offset. + * @param user_id Parameter user_id. + * @returns Promise. + */ export async function getMessages( send: BoundSendFn, amount: number, diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index cdb6879..d95d2e1 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -10,6 +10,11 @@ import { useSocket } from "@tensamin/ttp/context"; import { useCrypto } from "@tensamin/crypto/context"; import { log, toast } from "@tensamin/shared/log"; +/** + * Executes InputComponent. + * @param none This function has no parameters. + * @returns unknown. + */ export default function InputComponent() { const [value, setValue] = React.useState(""); const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); @@ -25,6 +30,11 @@ export default function InputComponent() { }); }, [load]); + /** + * Executes handleSubmit. + * @param none This function has no parameters. + * @returns unknown. + */ async function handleSubmit() { if (value.trim() === "") return; diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 184451a..edb64d5 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -5,6 +5,11 @@ import type { RawMessage } from "../values"; import { log } from "@tensamin/shared/log"; import Text from "@tensamin/markdown/text"; +/** + * Executes Message. + * @param props Parameter props. + * @returns unknown. + */ export default function Message(props: { message: RawMessage; notEncrypted?: boolean; diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 12661a5..4345a7f 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -11,8 +11,13 @@ export const context = React.createContext(undefined); const queryClient = new QueryClient(); +/** + * Executes Provider. + * @param props Parameter props. + * @returns unknown. + */ export default function Provider(props: { children: React.ReactNode }) { - const { get_shared_secret } = useCrypto(); + const { getSharedSecret } = useCrypto(); const { get } = useUser(); const { load } = useStorage(); const { send } = useSocket(); @@ -47,7 +52,7 @@ export default function Provider(props: { children: React.ReactNode }) { const ownId = await load("user_id"); const privateKey = await load("private_key"); const ownData = await get(ownId); - const sharedSecret = await get_shared_secret( + const sharedSecret = await getSharedSecret( privateKey, ownData.public_key, recipientData.public_key, @@ -66,7 +71,7 @@ export default function Provider(props: { children: React.ReactNode }) { return () => { active = false; }; - }, [get, get_shared_secret, load, userIdValue]); + }, [get, getSharedSecret, load, userIdValue]); const customGetMessages = React.useCallback( async (amount: number, offset: number) => { @@ -130,6 +135,11 @@ type contextType = { userId: () => number; }; +/** + * Executes useChat. + * @param none This function has no parameters. + * @returns contextType. + */ export function useChat(): contextType { const ctx = React.useContext(context); if (!ctx) { diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 6e9d5a7..807949c 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -8,6 +8,10 @@ import Message from "./components/message"; const PAGE_SIZE = 50; +/** + * Renders the chat screen with virtualized history and live message updates. + * @returns Chat screen JSX. + */ export default function Screen() { const { getMessages, liveMessages, clearLiveMessages, userId } = useChat(); @@ -79,6 +83,10 @@ export default function Screen() { overscan: 6, }); + /** + * Loads the next page when the scroll container reaches the top. + * @returns Promise that resolves once pagination handling completes. + */ const onScroll = React.useCallback(async () => { if (!scrollRef.current) { return; @@ -159,15 +167,21 @@ export default function Screen() { }); }, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]); + /** + * Triggers asynchronous scroll pagination without returning a promise to JSX. + * @returns Void. + */ + const handleContainerScroll = React.useCallback((): void => { + void onScroll(); + }, [onScroll]); + return (
{ - void onScroll(); - }} + onScroll={handleContainerScroll} >
unknown; + export const test: (...args: unknown[]) => unknown; + export const it: (...args: unknown[]) => unknown; + export const expect: (value: unknown) => { + toBe: (expected: unknown) => void; + toEqual: (expected: unknown) => void; + toContain: (expected: unknown) => void; + toThrow: (expected?: unknown) => void; + }; +} diff --git a/packages/crypto/src/context.test.ts b/packages/crypto/src/context.test.ts new file mode 100644 index 0000000..68de5fd --- /dev/null +++ b/packages/crypto/src/context.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { createCryptoActions } from "./context"; + +/** + * Creates a rejected API getter used to verify initialization guards. + * @returns Null API reference. + */ +function getUninitializedApi(): null { + return null; +} + +describe("createCryptoActions", () => { + test("throws when API is not initialized", async () => { + const actions = createCryptoActions(getUninitializedApi); + + let failed = false; + try { + await actions.encrypt("ab", "plain"); + } catch (error) { + failed = (error as Error).message.includes("API not initialized"); + } + + expect(failed).toBe(true); + }); + + test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => { + const api = { + encrypt: async (secret: string, plaintext: string): Promise => + `${secret}:${plaintext}`, + decrypt: async (secret: string, ciphertext: string): Promise => + `${secret}|${ciphertext}`, + getSharedSecret: async ( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, + ): Promise => + `${ownPrivateKey}.${ownPublicKey}.${otherPublicKey}`, + }; + + const actions = createCryptoActions(() => api); + + expect(await actions.encrypt("s", "p")).toBe("s:p"); + expect(await actions.decrypt("s", "c")).toBe("s|c"); + expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c"); + }); +}); diff --git a/packages/crypto/src/context.tsx b/packages/crypto/src/context.tsx index 5c18c82..e5d3315 100644 --- a/packages/crypto/src/context.tsx +++ b/packages/crypto/src/context.tsx @@ -1,14 +1,39 @@ import * as React from "react"; import * as Comlink from "comlink"; -import Loading from "@tensamin/ui/screens/loading"; -export const context = React.createContext(undefined); +type CryptoContextType = { + decrypt: (secret: string, ciphertext: string) => Promise; + encrypt: (secret: string, plaintext: string) => Promise; + getSharedSecret: ( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, + ) => Promise; +}; +type ApiRef = { + encrypt: (secret: string, plaintext: string) => Promise; + decrypt: (secret: string, ciphertext: string) => Promise; + getSharedSecret: ( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, + ) => Promise; +}; + +export const context = React.createContext( + undefined, +); + +/** + * Provides cryptographic actions backed by a worker without coupling to UI state. + * @param props Component props with children. + * @returns Crypto context provider JSX. + */ export default function Provider(props: { children: React.ReactNode }) { const apiRef = React.useRef(null); - const [isWorkerReady, setIsWorkerReady] = React.useState(false); - const { encrypt, decrypt, get_shared_secret } = React.useMemo( + const value = React.useMemo( () => createCryptoActions(() => apiRef.current), [], ); @@ -18,83 +43,84 @@ export default function Provider(props: { children: React.ReactNode }) { type: "module", }); - apiRef.current = Comlink.wrap(worker); - setIsWorkerReady(true); + apiRef.current = Comlink.wrap(worker); return () => { apiRef.current = null; worker.terminate(); - setIsWorkerReady(false); }; }, []); - if (!isWorkerReady) { - return ; - } - - return ( - - {props.children} - - ); + return {props.children}; } -type contextType = { - decrypt: (secret: string, data: string) => Promise; - encrypt: (secret: string, data: string) => Promise; - get_shared_secret: ( +/** + * Creates crypto action functions that safely delegate to the worker API. + * @param getApiRef Function that returns worker API reference when initialized. + * @returns Typed crypto action functions. + */ +export function createCryptoActions( + getApiRef: () => ApiRef | null, +): CryptoContextType { + /** + * Encrypts plaintext by delegating to the crypto worker API. + * @param secret Hex-encoded shared secret. + * @param plaintext Plaintext to encrypt. + * @returns Encrypted ciphertext. + */ + const encrypt = async ( + secret: string, + plaintext: string, + ): Promise => { + const apiRef = getApiRef(); + if (!apiRef) throw new Error("API not initialized"); + return await apiRef.encrypt(secret, plaintext); + }; + + /** + * Decrypts ciphertext by delegating to the crypto worker API. + * @param secret Hex-encoded shared secret. + * @param ciphertext Ciphertext to decrypt. + * @returns Decrypted plaintext. + */ + const decrypt = async ( + secret: string, + ciphertext: string, + ): Promise => { + const apiRef = getApiRef(); + if (!apiRef) throw new Error("API not initialized"); + return await apiRef.decrypt(secret, ciphertext); + }; + + /** + * Derives a shared secret from local and peer key material via the worker API. + * @param ownPrivateKey Local private key. + * @param ownPublicKey Local public key. + * @param otherPublicKey Peer public key. + * @returns Hex-encoded shared secret. + */ + const getSharedSecret = async ( ownPrivateKey: string, ownPublicKey: string, otherPublicKey: string, - ) => Promise; -}; - -type ApiRef = { - encrypt: (secret: string, message: string) => Promise; - decrypt: (secret: string, encryptedMessage: string) => Promise; - get_shared_secret: ( - own_private_key: string, - own_public_key: string, - other_public_key: string, - ) => Promise; -}; - -export function createCryptoActions( - getApiRef: () => ApiRef | null, -): contextType { - const encrypt = async (secret: string, message: string): Promise => { - const apiRef = getApiRef(); - if (!apiRef) throw new Error("API not initialized"); - return await apiRef.encrypt(secret, message); - }; - - const decrypt = async ( - secret: string, - encryptedMessage: string, ): Promise => { const apiRef = getApiRef(); if (!apiRef) throw new Error("API not initialized"); - return await apiRef.decrypt(secret, encryptedMessage); - }; - - const get_shared_secret = async ( - own_private_key: string, - own_public_key: string, - other_public_key: string, - ): Promise => { - const apiRef = getApiRef(); - if (!apiRef) throw new Error("API not initialized"); - return await apiRef.get_shared_secret( - own_private_key, - own_public_key, - other_public_key, + return await apiRef.getSharedSecret( + ownPrivateKey, + ownPublicKey, + otherPublicKey, ); }; - return { encrypt, decrypt, get_shared_secret }; + return { encrypt, decrypt, getSharedSecret }; } -export function useCrypto(): contextType { +/** + * Returns the crypto actions from the nearest provider. + * Throws when used outside of the crypto provider tree. + */ +export function useCrypto(): CryptoContextType { const ctx = React.useContext(context); if (!ctx) { throw new Error("useCrypto must be used within a CryptoProvider"); diff --git a/packages/crypto/src/worker.test.ts b/packages/crypto/src/worker.test.ts new file mode 100644 index 0000000..3d0020d --- /dev/null +++ b/packages/crypto/src/worker.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { x448 } from "@noble/curves/ed448.js"; +import { decrypt, encrypt, getSharedSecret } from "./worker"; + +/** + * Encodes bytes to URL-safe base64 without padding. + * @param value Input bytes. + * @returns Base64url string. + */ +function bytesToB64u(value: Uint8Array): string { + const b64 = Buffer.from(value).toString("base64"); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +/** + * Converts bytes to lowercase hex. + * @param value Input bytes. + * @returns Hex string. + */ +function bytesToHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} + +/** + * Creates deterministic 56-byte private key material for tests. + * @param seed Offset seed used to vary generated bytes. + * @returns Deterministic private key bytes. + */ +function createPrivateKey(seed: number): Uint8Array { + const output = new Uint8Array(56); + + for (let index = 0; index < output.length; index += 1) { + output[index] = (seed + index) % 255; + } + + return output; +} + +describe("crypto worker", () => { + test("encrypt/decrypt round-trip returns original plaintext", async () => { + const secret = "a1".repeat(56); + const plaintext = "hello encrypted world"; + + const ciphertext = await encrypt(secret, plaintext); + const decrypted = await decrypt(secret, ciphertext); + + expect(decrypted).toBe(plaintext); + }); + + test("decrypt fails with wrong shared secret", async () => { + const secret = "0f".repeat(56); + const wrongSecret = "f0".repeat(56); + const plaintext = "sensitive"; + + const ciphertext = await encrypt(secret, plaintext); + + let failed = false; + try { + await decrypt(wrongSecret, ciphertext); + } catch { + failed = true; + } + + expect(failed).toBe(true); + }); + + test("getSharedSecret matches noble x448 derivation", async () => { + const ownPrivateBytes = createPrivateKey(7); + const peerPrivateBytes = createPrivateKey(23); + + const ownPublicBytes = x448.getPublicKey(ownPrivateBytes); + const peerPublicBytes = x448.getPublicKey(peerPrivateBytes); + + const expected = bytesToHex( + new Uint8Array(x448.getSharedSecret(ownPrivateBytes, peerPublicBytes)), + ); + + const actual = await getSharedSecret( + bytesToB64u(ownPrivateBytes), + bytesToB64u(ownPublicBytes), + bytesToB64u(peerPublicBytes), + ); + + expect(actual).toBe(expected); + }); +}); diff --git a/packages/crypto/src/worker.ts b/packages/crypto/src/worker.ts index 4a25e67..6c8044f 100644 --- a/packages/crypto/src/worker.ts +++ b/packages/crypto/src/worker.ts @@ -12,12 +12,18 @@ type JWK = { const textEncoder = new TextEncoder(); const crypto = globalThis.crypto; +/** + * Encrypts plaintext with a symmetric key derived from a hex shared secret. + * @param secret Hex-encoded shared secret. + * @param plaintext UTF-8 plaintext to encrypt. + * @returns Base64-encoded ciphertext. + */ export async function encrypt( - password: string, - input: string, + secret: string, + plaintext: string, ): Promise { const sharedSecret = new Uint8Array( - password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), + secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), ); const hkdfKey = await crypto.subtle.importKey( @@ -54,21 +60,29 @@ export async function encrypt( const encryptedBuffer = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, aesKey, - textEncoder.encode(input), + textEncoder.encode(plaintext), ); return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); } +/** + * Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret. + * @param secret Hex-encoded shared secret. + * @param ciphertext Base64 ciphertext to decrypt. + * @returns Decrypted UTF-8 plaintext. + */ export async function decrypt( - password: string, - input: Base64URLString | string, + secret: string, + ciphertext: Base64URLString | string, ): Promise { const sharedSecret = new Uint8Array( - password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), + secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), ); - const ciphertext = Uint8Array.from(atob(input), (c) => c.charCodeAt(0)); + const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) => + c.charCodeAt(0), + ); const hkdfKey = await crypto.subtle.importKey( "raw", @@ -107,28 +121,45 @@ export async function decrypt( iv: nonce, }, aesKey, - ciphertext, + ciphertextBytes, ); return new TextDecoder().decode(decryptedBuffer); } -export async function get_shared_secret( - own_private_key: string, - own_public_key: string, - other_public_key: string, +/** + * Computes an X448 shared secret from local and peer key material. + * @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form. + * @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form. + * @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form. + * @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid. + */ +export async function getSharedSecret( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, ): Promise { - const other_jwk: JWK = { kty: "OKP", crv: "X448", x: other_public_key }; - const own_jwk: JWK = { + const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey }; + const ownJwk: JWK = { kty: "OKP", crv: "X448", - x: own_public_key, - d: own_private_key, + x: ownPublicKey, + d: ownPrivateKey, }; + /** + * Converts bytes to a lowercase hex string. + * @param u8 Byte array. + * @returns Hex string. + */ const bytesToHex = (u8: Uint8Array): string => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); + /** + * Decodes standard base64 text into bytes. + * @param s Base64 string. + * @returns Decoded bytes. + */ const b64ToBytes = (s: Base64URLString): Uint8Array => { const bin = atob(s); const out = new Uint8Array(bin.length); @@ -136,20 +167,41 @@ export async function get_shared_secret( return out; }; + /** + * Decodes URL-safe base64 text into bytes. + * @param s Base64url string. + * @returns Decoded bytes. + */ const b64uToBytes = (s: Base64URLString): Uint8Array => { const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); return b64ToBytes(b64); }; + /** + * Encodes bytes as URL-safe base64 without padding. + * @param u8 Byte array. + * @returns Base64url string. + */ const bytesToB64u = (u8: Uint8Array): string => { const b64 = btoa(String.fromCharCode(...u8)); return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); }; + /** + * Decodes either base64 or base64url text into bytes. + * @param s Base64/base64url string. + * @returns Decoded bytes. + */ const decodeBase64Auto = (s: string): Uint8Array => /[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s); + /** + * Reads a DER TLV item from the provided offset. + * @param view DER-encoded bytes. + * @param off Start offset. + * @returns Parsed TLV metadata with tag, length, and boundaries. + */ const readTLV = (view: Uint8Array, off: number) => { const tag = view[off++]; if (off >= view.length) throw new Error("DER: truncated"); @@ -167,6 +219,12 @@ export async function get_shared_secret( return { tag, len, start, end }; }; + /** + * Validates that a DER OID matches X448. + * @param view DER-encoded bytes. + * @param start Offset of the OID TLV. + * @returns True when the OID is X448. + */ const ensureOidX448 = (view: Uint8Array, start: number): boolean => { const oid = readTLV(view, start); if (oid.tag !== 0x06) return false; @@ -179,6 +237,11 @@ export async function get_shared_secret( ); }; + /** + * Extracts raw 56-byte X448 public key material from SPKI bytes. + * @param spkiBytes DER-encoded SPKI bytes. + * @returns Raw X448 public key bytes. + */ const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => { const view = spkiBytes; const outer = readTLV(view, 0); @@ -196,6 +259,11 @@ export async function get_shared_secret( return raw; }; + /** + * Extracts raw 56-byte X448 private key material from PKCS#8 bytes. + * @param pkcs8Bytes DER-encoded PKCS#8 bytes. + * @returns Raw X448 private key bytes. + */ const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => { const view = pkcs8Bytes; const outer = readTLV(view, 0); @@ -230,6 +298,12 @@ export async function get_shared_secret( return raw; }; + /** + * Normalizes X448 JWK fields into raw base64url key material. + * @param jwk Candidate JWK. + * @param label Error label for diagnostics. + * @returns Normalized JWK suitable for WebCrypto import. + */ const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => { if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") { throw new Error(`${label}: expected OKP JWK with crv "X448"`); @@ -271,6 +345,10 @@ export async function get_shared_secret( return out; }; + /** + * Returns WebCrypto subtle API when available. + * @returns SubtleCrypto instance or undefined. + */ const getSubtle = () => globalThis.crypto?.subtle; { @@ -305,8 +383,8 @@ export async function get_shared_secret( */ } - const myJwk: JWK = normalizeOkpX448Jwk(own_jwk, "own_jwk"); - const peerJwk: JWK = normalizeOkpX448Jwk(other_jwk, "other_jwk"); + const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk"); + const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk"); const subtle = getSubtle(); //const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`; @@ -357,8 +435,33 @@ export async function get_shared_secret( return bytesToHex(sharedSecret); } -Comlink.expose({ - encrypt, - decrypt, - get_shared_secret, -}); +/** + * @deprecated Use getSharedSecret instead. + * @param ownPrivateKey Local private key. + * @param ownPublicKey Local public key. + * @param otherPublicKey Peer public key. + * @returns Shared secret derived by getSharedSecret. + */ +export async function get_shared_secret( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, +): Promise { + return await getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey); +} + +/** + * Checks whether the current runtime context is a worker global scope. + * @returns True when executed inside a worker-like runtime. + */ +function isWorkerRuntime(): boolean { + return "postMessage" in globalThis && "importScripts" in globalThis; +} + +if (isWorkerRuntime()) { + Comlink.expose({ + encrypt, + decrypt, + getSharedSecret, + }); +} diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index eef1971..dbeb3b4 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -70,6 +70,11 @@ const markdownDecorations = ViewPlugin.fromClass( }, ); +/** + * Executes Input. + * @param props Parameter props. + * @returns unknown. + */ export default function Input(props: InputProps) { ensureMarkdownStyles(); @@ -132,6 +137,14 @@ export default function Input(props: InputProps) { return
; } +/** + * Executes createEditorExtensions. + * @param onChange Parameter onChange. + * @param getPlaceholder Parameter getPlaceholder. + * @param getInvertEnterBehavior Parameter getInvertEnterBehavior. + * @param onSubmit Parameter onSubmit. + * @returns Extension[]. + */ function createEditorExtensions( onChange: (value: string) => void, getPlaceholder: () => string | undefined, @@ -191,6 +204,11 @@ function createEditorExtensions( ]; } +/** + * Executes buildDecorations. + * @param view Parameter view. + * @returns DecorationSet. + */ function buildDecorations(view: EditorView): DecorationSet { const builder: Range[] = []; const selections = view.state.selection.ranges.map( diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx index aa54599..4dd05f6 100644 --- a/packages/markdown/src/markdown.tsx +++ b/packages/markdown/src/markdown.tsx @@ -75,6 +75,11 @@ type MarkdownBlock = const INLINE_TOKEN_REGEX = /!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g; +/** + * Executes parseInlineNodes. + * @param input Parameter input. + * @returns InlineNode[]. + */ export function parseInlineNodes(input: string): InlineNode[] { const nodes: InlineNode[] = []; @@ -121,6 +126,15 @@ export function parseInlineNodes(input: string): InlineNode[] { return nodes; } +/** + * Executes collectInlineRanges. + * @param input Parameter input. + * @param offset Parameter offset. + * @returns { + styleRanges: InlineDecorationRange[]; + tokenRanges: InlineTokenRange[]; +}. + */ export function collectInlineRanges( input: string, offset = 0, @@ -218,6 +232,11 @@ export function collectInlineRanges( return { styleRanges, tokenRanges }; } +/** + * Executes parseMarkdownBlocks. + * @param markdown Parameter markdown. + * @returns MarkdownBlock[]. + */ export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const blocks: MarkdownBlock[] = []; @@ -345,6 +364,11 @@ export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { return blocks; } +/** + * Executes renderInline. + * @param nodes Parameter nodes. + * @returns React.ReactNode[]. + */ export function renderInline(nodes: InlineNode[]): React.ReactNode[] { return nodes.map((node, index) => { if (node.type === "text") { @@ -410,6 +434,11 @@ export function renderInline(nodes: InlineNode[]): React.ReactNode[] { }); } +/** + * Executes renderBlocks. + * @param blocks Parameter blocks. + * @returns React.ReactElement. + */ export function renderBlocks(blocks: MarkdownBlock[]): React.ReactElement { return ( <> @@ -545,6 +574,11 @@ export function renderBlocks(blocks: MarkdownBlock[]): React.ReactElement { ); } +/** + * Executes normalizeUrl. + * @param input Parameter input. + * @returns string. + */ function normalizeUrl(input: string): string { const value = input.trim(); if (/^(https?:|mailto:|tel:|\/)/i.test(value)) { @@ -554,11 +588,22 @@ function normalizeUrl(input: string): string { return "#"; } +/** + * Executes splitTableRow. + * @param row Parameter row. + * @returns string[]. + */ function splitTableRow(row: string): string[] { const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, ""); return cleaned.split("|").map((cell) => cell.trim()); } +/** + * Executes readTable. + * @param lines Parameter lines. + * @param index Parameter index. + * @returns { block: TableBlock; nextIndex: number } | null. + */ function readTable( lines: string[], index: number, @@ -630,6 +675,11 @@ export const markdownStyles = ` .cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; } `; +/** + * Executes ensureMarkdownStyles. + * @param none This function has no parameters. + * @returns void. + */ export function ensureMarkdownStyles(): void { if (typeof document === "undefined") return; diff --git a/packages/markdown/src/text.tsx b/packages/markdown/src/text.tsx index 171d4e8..07e1bb8 100644 --- a/packages/markdown/src/text.tsx +++ b/packages/markdown/src/text.tsx @@ -10,6 +10,11 @@ export type TextProps = { value: string; }; +/** + * Executes Text. + * @param props Parameter props. + * @returns unknown. + */ export default function Text(props: TextProps) { ensureMarkdownStyles(); diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 8d408c0..781bb47 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -2,6 +2,11 @@ import * as React from "react"; export const context = React.createContext(undefined); +/** + * Executes Provider. + * @param props Parameter props. + * @returns unknown. + */ export default function Provider(props: { children: React.ReactNode }) { // live_messages @@ -16,6 +21,11 @@ type contextType = { test: () => void; }; +/** + * Executes useNotifications. + * @param none This function has no parameters. + * @returns contextType. + */ export function useNotifications(): contextType { const ctx = React.useContext(context); if (!ctx) { diff --git a/packages/shared/src/log.tsx b/packages/shared/src/log.tsx index 751e213..c8d4a3c 100644 --- a/packages/shared/src/log.tsx +++ b/packages/shared/src/log.tsx @@ -1,6 +1,14 @@ 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. + */ export function log( logLevel: number, logger: string, @@ -23,6 +31,12 @@ export function log( } const size = 20; +/** + * Executes toast. + * @param type Parameter type. + * @param message Parameter message. + * @returns unknown. + */ export function toast( type: "error" | "info" | "warn" | "success", message: string, diff --git a/packages/storage/src/context.tsx b/packages/storage/src/context.tsx index 2be043c..e3bad3e 100644 --- a/packages/storage/src/context.tsx +++ b/packages/storage/src/context.tsx @@ -22,6 +22,11 @@ const StorageContext = React.createContext( const isIndexedDBSupported = typeof indexedDB !== "undefined"; +/** + * Executes StorageProvider. + * @param props Parameter props. + * @returns unknown. + */ export default function StorageProvider(props: { children: React.ReactNode }) { const [storage, setStorage] = React.useState(defaults); const storageRef = React.useRef(storage); @@ -135,6 +140,11 @@ export default function StorageProvider(props: { children: React.ReactNode }) { ); } +/** + * Executes useStorage. + * @param none This function has no parameters. + * @returns StorageContextValue. + */ export function useStorage(): StorageContextValue { const context = React.useContext(StorageContext); if (!context) { diff --git a/packages/storage/src/indexed-db.ts b/packages/storage/src/indexed-db.ts index 3cb2b7e..ebdc58d 100644 --- a/packages/storage/src/indexed-db.ts +++ b/packages/storage/src/indexed-db.ts @@ -6,6 +6,11 @@ const STORE_NAME = "storage"; let dbPromise: Promise | null = null; +/** + * Executes openDB. + * @param none This function has no parameters. + * @returns Promise. + */ function openDB(): Promise { if (dbPromise) return dbPromise; @@ -26,6 +31,11 @@ function openDB(): Promise { return dbPromise; } +/** + * Executes getEntry. + * @param key Parameter key. + * @returns Promise. + */ export async function getEntry( key: K, ): Promise { @@ -41,6 +51,12 @@ export async function getEntry( }); } +/** + * Executes setEntry. + * @param key Parameter key. + * @param value Parameter value. + * @returns Promise. + */ export async function setEntry( key: K, value: StorageSchema[K], @@ -56,6 +72,11 @@ export async function setEntry( }); } +/** + * Executes deleteEntry. + * @param key Parameter key. + * @returns Promise. + */ export async function deleteEntry( key: K, ): Promise { diff --git a/packages/ttp/package.json b/packages/ttp/package.json index da387a1..6031b6b 100644 --- a/packages/ttp/package.json +++ b/packages/ttp/package.json @@ -12,7 +12,8 @@ "scripts": { "format": "bunx prettier --write .", "lint": "eslint src --ext .ts,.tsx", - "build": "tsc -p tsconfig.json --noEmit" + "test": "bun test", + "build": "bun run test && tsc -p tsconfig.json --noEmit" }, "dependencies": { "@tanstack/react-router": "^1.0.0", diff --git a/packages/ttp/src/bun-test.d.ts b/packages/ttp/src/bun-test.d.ts new file mode 100644 index 0000000..4ac4faa --- /dev/null +++ b/packages/ttp/src/bun-test.d.ts @@ -0,0 +1,11 @@ +declare module "bun:test" { + export const describe: (...args: unknown[]) => unknown; + export const test: (...args: unknown[]) => unknown; + export const it: (...args: unknown[]) => unknown; + export const expect: (value: unknown) => { + toBe: (expected: unknown) => void; + toEqual: (expected: unknown) => void; + toContain: (expected: unknown) => void; + toThrow: (expected?: unknown) => void; + }; +} diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index 62a8c89..63c0af8 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -26,6 +26,11 @@ const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([ "error_not_authenticated", ]); +/** + * Detects whether an error chain contains a STOP_SENDING transport signal. + * @param error Unknown error value from transport operations. + * @returns True when the error represents a STOP_SENDING condition. + */ function isStopSendingError(error: unknown) { if (typeof error === "string") { return error.includes("STOP_SENDING"); @@ -54,6 +59,11 @@ function isStopSendingError(error: unknown) { return false; } +/** + * Classifies identification errors that should be treated as terminal. + * @param error Unknown error raised during identification. + * @returns True when identification should fail without retry. + */ function isFatalIdentificationError(error: unknown) { if (typeof error === "object" && error !== null && "type" in error) { const type = (error as { type?: unknown }).type; @@ -90,9 +100,14 @@ type ContextType = { const socketContext = React.createContext(undefined); +/** + * Provides socket transport state and authenticated send operations to children. + * @param props Component props with children. + * @returns Loading, error, or provider-wrapped JSX. + */ export default function Provider(props: { children: React.ReactNode }) { const { load } = useStorage(); - const { decrypt, get_shared_secret } = useCrypto(); + const { decrypt, getSharedSecret } = useCrypto(); const [readyState, setReadyState] = React.useState( READY_STATE.CLOSED, @@ -112,6 +127,13 @@ export default function Provider(props: { children: React.ReactNode }) { > | null>(null); const identificationStartedRef = React.useRef(false); + /** + * Sends typed protocol messages through the active transport client. + * @param type Protocol message type. + * @param data Optional request payload. + * @param options Optional request id and response mode. + * @returns A promise for either void (no response) or typed message payload. + */ const send = React.useCallback>( (( type: string, @@ -175,6 +197,10 @@ export default function Provider(props: { children: React.ReactNode }) { let reconnectScheduled = false; let disposed = false; + /** + * Clears any scheduled reconnect timeout and resets scheduling flags. + * @returns Void. + */ const clearReconnectTimer = () => { if (!reconnectTimer) { return; @@ -185,6 +211,11 @@ export default function Provider(props: { children: React.ReactNode }) { reconnectScheduled = false; }; + /** + * Schedules a delayed reconnect attempt unless retries are exhausted. + * @param reason Optional reason for reconnect scheduling. + * @returns Void. + */ const scheduleReconnect = (reason?: unknown) => { if (disposed || reconnectScheduled) { return; @@ -256,6 +287,10 @@ export default function Provider(props: { children: React.ReactNode }) { clientRef.current = transportClient; + /** + * Establishes the transport connection and schedules reconnect on failures. + * @returns Promise that resolves after one connection attempt. + */ async function connect() { if (disposed) { return; @@ -318,6 +353,10 @@ export default function Provider(props: { children: React.ReactNode }) { setIdentifying(true); setIdentified(false); + /** + * Executes the challenge-response identification handshake. + * @returns Promise that resolves when identification flow completes. + */ const identify = async () => { try { const userId = await load("user_id"); @@ -335,7 +374,7 @@ export default function Provider(props: { children: React.ReactNode }) { user_id: userId, }); - const sharedSecret = await get_shared_secret( + const sharedSecret = await getSharedSecret( privateKey, "", challengeEnvelope.data.public_key, @@ -405,7 +444,7 @@ export default function Provider(props: { children: React.ReactNode }) { return () => { cancelled = true; }; - }, [connected, decrypt, get_shared_secret, load, send]); + }, [connected, decrypt, getSharedSecret, load, send]); const progress = React.useMemo(() => { if (readyState === READY_STATE.CONNECTING) return 30; @@ -472,6 +511,10 @@ export default function Provider(props: { children: React.ReactNode }) { ); } +/** + * Returns the active socket context and enforces provider usage. + * @returns Socket context API for transport operations and connection state. + */ export function useSocket(): ContextType { const context = React.useContext(socketContext); if (!context) { diff --git a/packages/ttp/src/core.test.ts b/packages/ttp/src/core.test.ts new file mode 100644 index 0000000..70c1285 --- /dev/null +++ b/packages/ttp/src/core.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { + decodeCommunicationMessage, + encodeCommunicationMessage, + type TypedMessage, +} from "./core"; + +/** + * Creates a representative protocol message used for round-trip codec tests. + * @returns Typed protocol message with mixed payload data kinds. + */ +function createRoundTripMessage(): TypedMessage> { + return { + id: 41, + type: "message", + data: { + accepted: true, + message: "hello", + user_id: 77, + iota_ids: [11, 12], + ping_iota: 33, + last_ping: 101, + get_variant: null, + user: { + user_id: 1, + username: "alice", + }, + messages: [ + { + user_id: 2, + message: "payload", + }, + ], + }, + }; +} + +describe("TTP communication codec", () => { + test("encodes and decodes a mixed payload message", () => { + const input = createRoundTripMessage(); + + const encoded = encodeCommunicationMessage(input); + const decoded = decodeCommunicationMessage(encoded); + + expect(decoded.id).toBe(41); + expect(decoded.type).toBe("message"); + expect(decoded.data).toEqual({ + accepted: true, + message: "hello", + user_id: 77, + iota_ids: [11, 12], + ping_iota: 33, + last_ping: 101, + get_variant: null, + user: { + user_id: 1, + username: "alice", + }, + messages: [ + { + user_id: 2, + message: "payload", + }, + ], + }); + }); + + test("throws for unknown communication type", () => { + expect(() => + encodeCommunicationMessage({ + id: 1, + type: "unknown_type", + data: { user_id: 1 }, + }), + ).toThrow("Unknown communication type"); + }); + + test("throws for unknown data key", () => { + expect(() => + encodeCommunicationMessage({ + id: 1, + type: "message", + data: { unknown_key: 1 }, + }), + ).toThrow("Unknown data type"); + }); +}); diff --git a/packages/ttp/src/core.ts b/packages/ttp/src/core.ts index dcfa76d..c3507a8 100644 --- a/packages/ttp/src/core.ts +++ b/packages/ttp/src/core.ts @@ -62,6 +62,9 @@ type ActiveConnection = { closeNotified: boolean; }; +/** + * Represents non-fatal payload decoding failures for individual protocol messages. + */ class RecoverableMessageDecodeError extends Error { readonly messageId: number; @@ -69,6 +72,12 @@ class RecoverableMessageDecodeError extends Error { readonly cause: unknown; + /** + * Creates a recoverable decode error associated with a specific message. + * @param messageId Protocol message id that failed to decode. + * @param messageType Protocol message type that failed to decode. + * @param cause Original decode failure cause. + */ constructor(messageId: number, messageType: string, cause: unknown) { super( `Failed to decode message payload for "${messageType}" (id=${messageId}): ${formatUnknownError(cause)}`, @@ -439,6 +448,12 @@ export type TransportClient = { subscribePush(handler: PushHandler): () => void; }; +/** + * Creates a typed transport client that validates request and response payloads. + * @param schemas Protocol schema map for request/response validation. + * @param options Optional transport lifecycle callbacks and default URL. + * @returns Transport client API for connect, close, send, and push subscriptions. + */ export function createTransportClient( schemas: T, options: TransportClientOptions = {}, @@ -451,11 +466,21 @@ export function createTransportClient( let nextRequestId = 1; let configuredUrl = options.url; + /** + * Updates current ready state and emits lifecycle callbacks. + * @param readyState New transport ready state value. + * @returns Void. + */ const setReadyState = (readyState: number) => { currentReadyState = readyState; options.onReadyStateChange?.(readyState); }; + /** + * Rejects all pending requests and clears timeout handles. + * @param reason Rejection reason applied to all pending requests. + * @returns Void. + */ const rejectPending = (reason: unknown) => { for (const [id, request] of pending) { clearTimeout(request.timeoutId); @@ -464,6 +489,12 @@ export function createTransportClient( } }; + /** + * Finalizes closed state for a connection and notifies listeners. + * @param connection Closed connection object. + * @param error Optional close error. + * @returns Void. + */ const notifyClosed = (connection: ActiveConnection, error?: unknown) => { if (connection.closeNotified) { return; @@ -483,6 +514,12 @@ export function createTransportClient( options.onClose?.({ error, intentional: connection.intentional }); }; + /** + * Handles connection-level failures and routes them through close handling. + * @param connection Connection that failed. + * @param error Optional failure reason. + * @returns Void. + */ const handleConnectionFailure = ( connection: ActiveConnection, error?: unknown, @@ -494,6 +531,12 @@ export function createTransportClient( notifyClosed(connection, error); }; + /** + * Handles STOP_SENDING failures by forcing close and notifying failure. + * @param connection Active connection. + * @param error Failure reason. + * @returns Void. + */ const closeFromStopSending = ( connection: ActiveConnection, error: unknown, @@ -514,6 +557,11 @@ export function createTransportClient( handleConnectionFailure(connection, error); }; + /** + * Handles decoded incoming messages and resolves request promises or push listeners. + * @param message Decoded incoming message. + * @returns Void. + */ const handleIncomingMessage = (message: TypedMessage) => { if (message.type !== "pong") { log(2, "Socket", "blue", "Received:", message.type, message.data, { @@ -591,6 +639,11 @@ export function createTransportClient( } }; + /** + * Handles recoverable decode failures by rejecting only the affected request. + * @param error Recoverable decode error details. + * @returns Void. + */ const handleRecoverableDecodeFailure = ( error: RecoverableMessageDecodeError, ) => { @@ -618,6 +671,11 @@ export function createTransportClient( ); }; + /** + * Starts the incoming stream loop for a newly-opened connection. + * @param connection Active connection instance. + * @returns Void. + */ const startIncomingLoop = (connection: ActiveConnection) => { connection.streamReader = connection.transport.incomingUnidirectionalStreams.getReader(); @@ -678,6 +736,11 @@ export function createTransportClient( })(); }; + /** + * Awaits transport closed promise and forwards outcome to failure handling. + * @param connection Active connection instance. + * @returns Void. + */ const awaitClosed = (connection: ActiveConnection) => { void connection.transport.closed .then(() => { @@ -688,6 +751,11 @@ export function createTransportClient( }); }; + /** + * Opens a transport connection and starts incoming frame processing. + * @param url Optional override transport URL. + * @returns Promise that resolves when connection setup completes. + */ const connect = async (url = configuredUrl) => { if (!url) { throw new Error("Transport URL is not configured"); @@ -729,6 +797,11 @@ export function createTransportClient( } }; + /** + * Closes the current transport connection and sends a close sentinel frame. + * @param reason Close reason sent to transport. + * @returns Promise that resolves once close handling completes. + */ const close = async (reason = APPLICATION_CLOSE_REASON) => { const connection = currentConnection; if (!connection) { @@ -769,6 +842,13 @@ export function createTransportClient( } }; + /** + * Sends a typed protocol request over the current connection. + * @param type Protocol message type. + * @param input Optional request payload. + * @param options Optional id and response behavior. + * @returns Promise for response message or void when no response is expected. + */ const send: BoundSendFn = (( type: string, input?: Record, @@ -887,6 +967,11 @@ export function createTransportClient( }; } +/** + * Builds a normalized lookup map for protocol names by index. + * @param values Ordered protocol names. + * @returns Map from normalized name to array index. + */ function createIndexMap(values: readonly string[]) { const map = new Map(); @@ -897,6 +982,12 @@ function createIndexMap(values: readonly string[]) { return map; } +/** + * Registers expected data kinds for protocol data type names. + * @param kind Expected scalar or array kind for the provided names. + * @param names Data type names to register. + * @returns Void. + */ function registerDataKinds(kind: DataKind, names: readonly string[]) { for (const name of names) { if (dataKindByType.has(name)) { @@ -907,6 +998,10 @@ function registerDataKinds(kind: DataKind, names: readonly string[]) { } } +/** + * Returns the WebTransport constructor from the current runtime. + * @returns WebTransport constructor. + */ function getWebTransportCtor() { const ctor = (globalThis as WebTransportGlobal).WebTransport; if (!ctor) { @@ -916,10 +1011,20 @@ function getWebTransportCtor() { return ctor; } +/** + * Normalizes protocol names by lowercasing and removing underscores. + * @param value Raw protocol name. + * @returns Normalized protocol key. + */ function normalizeName(value: string) { return value.toLowerCase().replaceAll("_", ""); } +/** + * Formats unknown errors into a stable log string. + * @param error Unknown error value. + * @returns Human-readable error description. + */ function formatUnknownError(error: unknown) { if (error instanceof Error) { return error.message; @@ -936,6 +1041,11 @@ function formatUnknownError(error: unknown) { } } +/** + * Detects whether an error chain includes STOP_SENDING. + * @param error Unknown transport error. + * @returns True when STOP_SENDING appears in the error chain. + */ function isStopSendingError(error: unknown) { if (typeof error === "string") { return error.includes("STOP_SENDING"); @@ -964,6 +1074,11 @@ function isStopSendingError(error: unknown) { return false; } +/** + * Ensures outbound message payloads are plain object records. + * @param value Candidate payload. + * @returns Payload as plain object record. + */ function coercePayload(value: unknown): Record { if (!isPlainObject(value)) { throw new Error("Protocol payload must be a plain object"); @@ -972,10 +1087,23 @@ function coercePayload(value: unknown): Record { return value; } +/** + * Checks whether a value is a non-null, non-array object. + * @param value Candidate value. + * @returns True when the value is a plain object. + */ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Resolves a unique request id for transport messages. + * @param requestedId Optional caller-provided request id. + * @param expectsResponse Whether the request expects a response. + * @param pending Map of currently pending requests. + * @param nextId Function that returns the next candidate id. + * @returns A request id valid for the current pending set. + */ function resolveRequestId( requestedId: number | undefined, expectsResponse: boolean, @@ -1010,6 +1138,12 @@ function resolveRequestId( return candidate; } +/** + * Validates request id bounds and response semantics. + * @param id Request id to validate. + * @param expectsResponse Whether a response is expected for this request. + * @returns Void. + */ function validateRequestId(id: number, expectsResponse: boolean) { if (!Number.isInteger(id) || id < 0 || id > MAX_REQUEST_ID) { throw new Error(`Request id must be a u32 between 0 and ${MAX_REQUEST_ID}`); @@ -1020,6 +1154,12 @@ function validateRequestId(id: number, expectsResponse: boolean) { } } +/** + * Writes a protocol message payload as a framed unidirectional transport stream. + * @param transport Active transport instance. + * @param payload Encoded message payload bytes. + * @returns Promise that resolves when frame writing is complete. + */ async function writeMessage(transport: WebTransportLike, payload: Uint8Array) { if (payload.byteLength >= CLOSE_FRAME_LEN) { throw new Error("Message too large for transport frame"); @@ -1040,6 +1180,11 @@ async function writeMessage(transport: WebTransportLike, payload: Uint8Array) { } } +/** + * Writes a close sentinel frame to the transport. + * @param transport Active transport instance. + * @returns Promise that resolves when the close frame is written. + */ async function writeCloseFrame(transport: WebTransportLike) { const stream = await transport.createUnidirectionalStream(); const writer = stream.getWriter(); @@ -1054,6 +1199,11 @@ async function writeCloseFrame(transport: WebTransportLike) { } } +/** + * Reads and validates a full transport frame from a stream. + * @param stream Incoming stream for one framed message. + * @returns Decoded typed message or null for close sentinel frames. + */ async function readFrame(stream: ReadableStream) { const payload = await readAll(stream); if (payload.byteLength < 4) { @@ -1075,6 +1225,11 @@ async function readFrame(stream: ReadableStream) { return decodeCommunicationMessage(payload.subarray(4)); } +/** + * Reads all chunks from a stream into a contiguous byte array. + * @param stream Stream providing Uint8Array chunks. + * @returns Concatenated stream bytes. + */ async function readAll(stream: ReadableStream) { const reader = stream.getReader(); const chunks: Uint8Array[] = []; @@ -1105,7 +1260,12 @@ async function readAll(stream: ReadableStream) { return buffer; } -function encodeCommunicationMessage( +/** + * Encodes a typed protocol message into the wire communication format. + * @param message Typed message with id, type, and payload data. + * @returns Encoded communication frame bytes. + */ +export function encodeCommunicationMessage( message: TypedMessage>, ) { const typeIndex = parseCommunicationType(message.type); @@ -1129,7 +1289,12 @@ function encodeCommunicationMessage( return buffer; } -function decodeCommunicationMessage(frame: Uint8Array): TypedMessage { +/** + * Decodes communication frame bytes into a typed protocol message. + * @param frame Encoded communication frame bytes. + * @returns Decoded typed protocol message. + */ +export function decodeCommunicationMessage(frame: Uint8Array): TypedMessage { const reader = new ByteReader(frame); const payloadLength = reader.readU32(); if (payloadLength !== frame.byteLength - 4) { @@ -1189,6 +1354,11 @@ function decodeCommunicationMessage(frame: Uint8Array): TypedMessage { }; } +/** + * Resolves a communication type string to its protocol index. + * @param type Protocol message type string. + * @returns Numeric protocol type index. + */ function parseCommunicationType(type: string) { const index = COMMUNICATION_TYPE_BY_NAME.get(normalizeName(type)); if (index === undefined) { @@ -1198,6 +1368,11 @@ function parseCommunicationType(type: string) { return index; } +/** + * Parses a protocol data key into its index and canonical name. + * @param type Raw protocol data key. + * @returns Canonical data key metadata with index and normalized name. + */ function parseDataType(type: string) { const index = DATA_TYPE_BY_NAME.get(normalizeName(type)); if (index === undefined) { @@ -1210,6 +1385,11 @@ function parseDataType(type: string) { }; } +/** + * Returns the expected value kind for a protocol data key. + * @param type Canonical data key name. + * @returns Expected data kind definition. + */ function getExpectedKind(type: string) { const kind = dataKindByType.get(type); if (!kind) { @@ -1224,6 +1404,13 @@ type EncodedDataValue = { payload: Uint8Array; }; +/** + * Encodes a value according to the expected protocol kind. + * @param kind Expected protocol kind. + * @param value Candidate value to encode. + * @param path Payload path used in validation errors. + * @returns Encoded value marker and payload bytes. + */ function encodeDataValueForKind( kind: DataKind, value: unknown, @@ -1285,6 +1472,12 @@ function encodeDataValueForKind( } } +/** + * Encodes a numeric payload as signed 64-bit big-endian bytes. + * @param value Value expected to be a safe integer number. + * @param path Payload path used in validation errors. + * @returns Encoded i64 byte array. + */ function encodeNumberPayload(value: unknown, path: string) { if ( typeof value !== "number" || @@ -1299,6 +1492,13 @@ function encodeNumberPayload(value: unknown, path: string) { return buffer; } +/** + * Encodes an array payload where each item is tagged with a data marker. + * @param innerKind Expected kind for array entries. + * @param value Candidate array payload. + * @param path Payload path used in validation errors. + * @returns Encoded array payload bytes. + */ function encodeArrayPayload( innerKind: PrimitiveDataKind | "container" | "null", value: unknown, @@ -1352,6 +1552,12 @@ function encodeArrayPayload( return buffer; } +/** + * Encodes a keyed container payload into protocol key-index/value entries. + * @param value Object payload to encode. + * @param path Payload path used in validation errors. + * @returns Encoded container payload bytes. + */ function encodeContainerPayload(value: Record, path: string) { const normalizedEntries = new Map< string, @@ -1433,6 +1639,12 @@ function encodeContainerPayload(value: Record, path: string) { return buffer; } +/** + * Decodes a value marker and payload bytes into a JavaScript value. + * @param marker Protocol value marker. + * @param payload Encoded payload bytes for the marker. + * @returns Decoded JavaScript value. + */ function decodeValuePayload(marker: number, payload: Uint8Array): unknown { const reader = new ByteReader(payload); @@ -1479,6 +1691,11 @@ function decodeValuePayload(marker: number, payload: Uint8Array): unknown { } } +/** + * Decodes an encoded array payload from a byte reader. + * @param reader Byte reader positioned at array payload start. + * @returns Decoded array values. + */ function decodeArrayPayload(reader: ByteReader) { const itemCount = reader.readU16(); const values: unknown[] = []; @@ -1499,6 +1716,11 @@ function decodeArrayPayload(reader: ByteReader) { return values; } +/** + * Decodes an encoded keyed container payload from a byte reader. + * @param reader Byte reader positioned at container payload start. + * @returns Decoded object payload. + */ function decodeContainerPayload(reader: ByteReader) { const entryCount = reader.readU16(); const value: Record = {}; @@ -1528,6 +1750,11 @@ function decodeContainerPayload(reader: ByteReader) { return value; } +/** + * Resolves a canonical data key by protocol index. + * @param index Protocol key index. + * @returns Canonical protocol data key name. + */ function getDataTypeNameByIndex(index: number) { const key = DATA_TYPES[index]; if (!key) { @@ -1537,6 +1764,11 @@ function getDataTypeNameByIndex(index: number) { return key; } +/** + * Checks whether a marker represents a boolean payload. + * @param marker Protocol value marker. + * @returns True when marker is boolean true or false. + */ function isBoolKindMarker(marker: number) { return ( marker === DATA_VALUE_KIND_BOOL_TRUE || @@ -1544,6 +1776,12 @@ function isBoolKindMarker(marker: number) { ); } +/** + * Validates that a marker is compatible with an expected data kind. + * @param marker Protocol value marker. + * @param kind Expected protocol data kind. + * @returns True when marker matches the expected kind. + */ function isMarkerCompatibleWithKind(marker: number, kind: DataKind) { if (typeof kind === "object") { return marker === DATA_VALUE_KIND_ARRAY; @@ -1563,6 +1801,13 @@ function isMarkerCompatibleWithKind(marker: number, kind: DataKind) { } } +/** + * Validates marker compatibility for a specific key with scalar-array fallback support. + * @param marker Protocol value marker. + * @param kind Expected protocol data kind. + * @param key Canonical protocol key name. + * @returns True when marker is compatible for the key. + */ function isMarkerCompatibleWithKey( marker: number, kind: DataKind, @@ -1578,6 +1823,12 @@ function isMarkerCompatibleWithKey( return isMarkerCompatibleWithKind(marker, kind); } +/** + * Normalizes outbound values for special scalar-array compatibility keys. + * @param type Canonical protocol key name. + * @param value Outbound value. + * @returns Normalized outbound value. + */ function normalizeOutgoingValue(type: string, value: unknown) { if (SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && typeof value === "number") { return [value]; @@ -1586,6 +1837,12 @@ function normalizeOutgoingValue(type: string, value: unknown) { return value; } +/** + * Normalizes inbound values for scalar-array compatibility keys. + * @param type Canonical protocol key name. + * @param value Inbound decoded value. + * @returns Normalized inbound value. + */ function normalizeIncomingValue(type: string, value: unknown) { if ( SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && @@ -1599,6 +1856,13 @@ function normalizeIncomingValue(type: string, value: unknown) { return value; } +/** + * Writes a big-endian unsigned 16-bit integer to a byte buffer. + * @param buffer Destination byte buffer. + * @param offset Byte offset to write at. + * @param value Unsigned 16-bit integer value. + * @returns Void. + */ function writeU16(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint16( offset, @@ -1607,6 +1871,13 @@ function writeU16(buffer: Uint8Array, offset: number, value: number) { ); } +/** + * Writes a big-endian unsigned 32-bit integer to a byte buffer. + * @param buffer Destination byte buffer. + * @param offset Byte offset to write at. + * @param value Unsigned 32-bit integer value. + * @returns Void. + */ function writeU32(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32( offset, @@ -1615,6 +1886,13 @@ function writeU32(buffer: Uint8Array, offset: number, value: number) { ); } +/** + * Writes a big-endian signed 64-bit integer to a byte buffer. + * @param buffer Destination byte buffer. + * @param offset Byte offset to write at. + * @param value Signed integer value. + * @returns Void. + */ function writeI64(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setBigInt64( offset, @@ -1623,6 +1901,12 @@ function writeI64(buffer: Uint8Array, offset: number, value: number) { ); } +/** + * Reads a big-endian unsigned 32-bit integer from a byte buffer. + * @param buffer Source byte buffer. + * @param offset Byte offset to read from. + * @returns Unsigned 32-bit integer value. + */ function readU32(buffer: Uint8Array, offset: number) { return new DataView( buffer.buffer, @@ -1631,6 +1915,9 @@ function readU32(buffer: Uint8Array, offset: number) { ).getUint32(offset, false); } +/** + * Provides sequential big-endian reads over protocol byte buffers. + */ class ByteReader { private readonly view: DataView; @@ -1638,11 +1925,19 @@ class ByteReader { private readonly bytes: Uint8Array; + /** + * Creates a byte reader over an immutable Uint8Array view. + * @param bytes Source bytes to read from. + */ constructor(bytes: Uint8Array) { this.bytes = bytes; this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); } + /** + * Reads one unsigned byte. + * @returns Unsigned 8-bit integer. + */ readU8() { this.ensureAvailable(1); const value = this.view.getUint8(this.offset); @@ -1650,6 +1945,10 @@ class ByteReader { return value; } + /** + * Reads two bytes as big-endian unsigned 16-bit integer. + * @returns Unsigned 16-bit integer. + */ readU16() { this.ensureAvailable(2); const value = this.view.getUint16(this.offset, false); @@ -1657,6 +1956,10 @@ class ByteReader { return value; } + /** + * Reads four bytes as big-endian unsigned 32-bit integer. + * @returns Unsigned 32-bit integer. + */ readU32() { this.ensureAvailable(4); const value = this.view.getUint32(this.offset, false); @@ -1664,6 +1967,10 @@ class ByteReader { return value; } + /** + * Reads eight bytes as big-endian signed 64-bit integer. + * @returns Safe integer representation of the decoded value. + */ readI64() { this.ensureAvailable(8); const value = this.view.getBigInt64(this.offset, false); @@ -1679,6 +1986,10 @@ class ByteReader { return numberValue; } + /** + * Reads six bytes as a big-endian unsigned 48-bit integer. + * @returns Unsigned 48-bit integer represented as number. + */ readU48() { this.ensureAvailable(6); const upper = this.view.getUint16(this.offset, false); @@ -1687,6 +1998,11 @@ class ByteReader { return upper * 2 ** 32 + lower; } + /** + * Reads a byte slice of the requested length. + * @param length Number of bytes to read. + * @returns View over the requested bytes. + */ readBytes(length: number) { this.ensureAvailable(length); const value = this.bytes.subarray(this.offset, this.offset + length); @@ -1694,10 +2010,19 @@ class ByteReader { return value; } + /** + * Indicates whether all bytes have been consumed. + * @returns True when reader offset is at buffer end. + */ isAtEnd() { return this.offset === this.bytes.byteLength; } + /** + * Ensures that at least a specific number of bytes can still be read. + * @param length Required available byte count. + * @returns Void. + */ private ensureAvailable(length: number) { if (this.offset + length > this.bytes.byteLength) { throw new Error("Unexpected end of protocol buffer"); diff --git a/packages/ui/src/cmp/avatar.tsx b/packages/ui/src/cmp/avatar.tsx index b08bf9e..ff7477b 100644 --- a/packages/ui/src/cmp/avatar.tsx +++ b/packages/ui/src/cmp/avatar.tsx @@ -3,6 +3,19 @@ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; import { cn } from "../lib/utils"; +/** + * Executes Avatar. + * @param { + className, + size = "default", + ...props +} Parameter { + className, + size = "default", + ...props +}. + * @returns unknown. + */ function Avatar({ className, size = "default", @@ -23,6 +36,11 @@ function Avatar({ ); } +/** + * Executes AvatarImage. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { return ( ) { return ( ) { ); } +/** + * Executes AvatarGroup. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes AvatarGroupCount. + * @param { + className, + ...props +} Parameter { + className, + ...props +}. + * @returns unknown. + */ function AvatarGroupCount({ className, ...props diff --git a/packages/ui/src/cmp/button.tsx b/packages/ui/src/cmp/button.tsx index b84f586..70ffcb6 100644 --- a/packages/ui/src/cmp/button.tsx +++ b/packages/ui/src/cmp/button.tsx @@ -38,6 +38,21 @@ const buttonVariants = cva( }, ); +/** + * Executes Button. + * @param { + className, + variant = "default", + size = "default", + ...props +} Parameter { + className, + variant = "default", + size = "default", + ...props +}. + * @returns unknown. + */ function Button({ className, variant = "default", diff --git a/packages/ui/src/cmp/card.tsx b/packages/ui/src/cmp/card.tsx index a08f2ba..74b4408 100644 --- a/packages/ui/src/cmp/card.tsx +++ b/packages/ui/src/cmp/card.tsx @@ -2,6 +2,19 @@ import * as React from "react"; import { cn } from "../lib/utils"; +/** + * Executes Card. + * @param { + className, + size = "default", + ...props +} Parameter { + className, + size = "default", + ...props +}. + * @returns unknown. + */ function Card({ className, size = "default", @@ -20,6 +33,11 @@ function Card({ ); } +/** + * Executes CardHeader. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes CardTitle. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes CardDescription. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes CardAction. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes CardContent. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } +/** + * Executes CardFooter. + * @param { className, ...props } Parameter { className, ...props }. + * @returns unknown. + */ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
; } +/** + * Executes ContextMenuPortal. + * @param { ...props } Parameter { ...props }. + * @returns unknown. + */ function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { return ( ); } +/** + * Executes ContextMenuTrigger. + * @param { + className, + ...props +} Parameter { + className, + ...props +}. + * @returns unknown. + */ function ContextMenuTrigger({ className, ...props @@ -27,6 +48,25 @@ function ContextMenuTrigger({ ); } +/** + * Executes ContextMenuContent. + * @param { + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +} Parameter { + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +}. + * @returns unknown. + */ function ContextMenuContent({ className, align = "start", @@ -61,12 +101,30 @@ function ContextMenuContent({ ); } +/** + * Executes ContextMenuGroup. + * @param { ...props } Parameter { ...props }. + * @returns unknown. + */ function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { return ( ); } +/** + * Executes ContextMenuLabel. + * @param { + className, + inset, + ...props +} Parameter { + className, + inset, + ...props +}. + * @returns unknown. + */ function ContextMenuLabel({ className, inset, @@ -87,6 +145,21 @@ function ContextMenuLabel({ ); } +/** + * Executes ContextMenuItem. + * @param { + className, + inset, + variant = "default", + ...props +} Parameter { + className, + inset, + variant = "default", + ...props +}. + * @returns unknown. + */ function ContextMenuItem({ className, inset, @@ -110,12 +183,32 @@ function ContextMenuItem({ ); } +/** + * Executes ContextMenuSub. + * @param { ...props } Parameter { ...props }. + * @returns unknown. + */ function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { return ( ); } +/** + * Executes ContextMenuSubTrigger. + * @param { + className, + inset, + children, + ...props +} Parameter { + className, + inset, + children, + ...props +}. + * @returns unknown. + */ function ContextMenuSubTrigger({ className, inset, @@ -140,6 +233,15 @@ function ContextMenuSubTrigger({ ); } +/** + * Executes ContextMenuSubContent. + * @param { + ...props +} Parameter { + ...props +}. + * @returns unknown. + */ function ContextMenuSubContent({ ...props }: React.ComponentProps) { @@ -153,6 +255,23 @@ function ContextMenuSubContent({ ); } +/** + * Executes ContextMenuCheckboxItem. + * @param { + className, + children, + checked, + inset, + ...props +} Parameter { + className, + children, + checked, + inset, + ...props +}. + * @returns unknown. + */ function ContextMenuCheckboxItem({ className, children, @@ -183,6 +302,15 @@ function ContextMenuCheckboxItem({ ); } +/** + * Executes ContextMenuRadioGroup. + * @param { + ...props +} Parameter { + ...props +}. + * @returns unknown. + */ function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) { @@ -194,6 +322,21 @@ function ContextMenuRadioGroup({ ); } +/** + * Executes ContextMenuRadioItem. + * @param { + className, + children, + inset, + ...props +} Parameter { + className, + children, + inset, + ...props +}. + * @returns unknown. + */ function ContextMenuRadioItem({ className, children, @@ -222,6 +365,17 @@ function ContextMenuRadioItem({ ); } +/** + * Executes ContextMenuSeparator. + * @param { + className, + ...props +} Parameter { + className, + ...props +}. + * @returns unknown. + */ function ContextMenuSeparator({ className, ...props @@ -235,6 +389,17 @@ function ContextMenuSeparator({ ); } +/** + * Executes ContextMenuShortcut. + * @param { + className, + ...props +} Parameter { + className, + ...props +}. + * @returns unknown. + */ function ContextMenuShortcut({ className, ...props diff --git a/packages/ui/src/cmp/input.tsx b/packages/ui/src/cmp/input.tsx index 96dfb55..4ff8733 100644 --- a/packages/ui/src/cmp/input.tsx +++ b/packages/ui/src/cmp/input.tsx @@ -3,6 +3,11 @@ import { Input as InputPrimitive } from "@base-ui/react/input"; import { cn } from "../lib/utils"; +/** + * Executes Input. + * @param { className, type, ...props } Parameter { className, type, ...props }. + * @returns unknown. + */ function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( ) { return (