Big Updated, added some tests, added comments for all functions, I forgot the rest

This commit is contained in:
Alois 2026-03-15 14:52:09 +01:00
commit 90a1059cc8
59 changed files with 1816 additions and 203 deletions

View file

@ -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": {

View file

@ -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 (
<Card className="animate-in fade-in duration-300 rounded-2xl">
<Card className="animate-in fade-in duration-300 rounded-2xl py-0">
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
<Avatar>
<AvatarImage src={props.user.avatar} />
@ -18,3 +24,11 @@ export default function Basic(props: { user: User }) {
</Card>
);
}
/**
* Executes Loading.
* @returns unknown.
*/
export function Loading() {
return <Skeleton className="h-12 rounded-xl" />;
}

View file

@ -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) {

View file

@ -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<typeof useNavigate>): 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<User | null>(null);
const currentId = React.useMemo(
@ -30,13 +51,11 @@ export default function Navbar() {
return (
<div className="w-full h-13.5 flex items-center justify-center">
<Button
onClick={() => {
void navigate({ to: "/" });
}}
className="w-9 h-9 aspect-square p-0 rounded-lg"
onClick={onHomeButtonClick}
className="w-9 h-9 aspect-square rounded-lg"
variant="outline"
>
<House size={18} />
<House className="size-4.5" />
</Button>
<p className="font-medium pl-3 text-md">{user?.display}</p>
<div className="w-full" />

View file

@ -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<HTMLInputElement | null>(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<HTMLInputElement>): Promise<void> => {
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<HTMLFormElement>): Promise<void> => {
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 (
<div className="flex gap-5">
<Card className="w-75 h-80">
@ -45,35 +152,13 @@ export default function Form() {
</CardHeader>
<CardContent className="h-full flex items-center justify-center">
<div
onClick={() => 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"
>
<Upload className="text-input/75" size={34} />
</div>
<input
onChange={async (e) => {
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() {
<CardContent>
<form
className="flex flex-col gap-5 h-full"
onSubmit={async (e) => {
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}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>

View file

@ -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 <Basic user={user} />;
}
/**
* Renders sidebar user summary content skeleton while loading user data.
* @returns Sidebar user card skeleton JSX.
*/
function renderSidebarUserLoading(): React.ReactNode {
return <Loading />;
}
/**
* 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 | number>(undefined);
const { load } = useStorage();
React.useEffect(() => {
@ -14,9 +36,11 @@ export default function Sidebar() {
return (
<div className="w-75 h-full flex flex-col gap-3 p-2">
{userId !== 0 && (
<Wrapper userId={userId} component={(user) => <Basic user={user} />} />
)}
<Wrapper
loading={renderSidebarUserLoading()}
userId={userId}
component={renderSidebarUser}
/>
<div className="h-full">
<List />
</div>

View file

@ -16,6 +16,11 @@ const ConversationContext = React.createContext<contextValue | undefined>(
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) {

View file

@ -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"

View file

@ -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",

View file

@ -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 <div>{props.community.community_title}</div>;
}

View file

@ -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 <Basic user={user} />;
}
/**
* Renders the conversation modal user preview card skeleton while loading user data.
* @returns Conversation preview card skeleton JSX.
*/
function renderConversationUserLoading() {
return <Loading />;
}
/**
* 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 (
<ContextMenu>
<ContextMenuTrigger>
<div
className="select-none cursor-pointer"
onClick={() => {
void navigate({ to: "/chat", search: { id: props.userId } });
}}
onClick={onConversationClick}
>
<Wrapper
loading={renderConversationUserLoading()}
userId={props.userId}
component={(user) => <Basic user={user} />}
component={renderConversationUser}
/>
</div>
</ContextMenuTrigger>

View file

@ -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<typeof useStorage>["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<typeof legalDocsSchema>,
setPPandToSDone: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
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<React.SetStateAction<boolean>>,
setUsageData: React.Dispatch<React.SetStateAction<boolean>>,
setDoneWithAnalytics: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
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 }) {
</div>
<ContinueButton
disabled={!acceptedPP || !acceptedTOS}
onClick={() => {
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 }) {
/>
</div>
</div>
<ContinueButton
onClick={() => {
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);
});
}}
/>
<ContinueButton onClick={handleContinueAnalytics} />
</>
)}
</div>
@ -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 (
<div className="w-full flex justify-end">
@ -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;

View file

@ -33,6 +33,11 @@ window.setLogLevelToMax = () => {
location.reload();
};
/**
* Executes RootShell.
* @param none This function has no parameters.
* @returns unknown.
*/
function RootShell() {
return (
<ThemeProvider>
@ -45,6 +50,11 @@ function RootShell() {
);
}
/**
* Executes AppShell.
* @param none This function has no parameters.
* @returns unknown.
*/
function AppShell() {
return (
<AppLayout>
@ -53,6 +63,11 @@ function AppShell() {
);
}
/**
* Executes Chat.
* @param none This function has no parameters.
* @returns unknown.
*/
function Chat() {
return (
<ChatContext>

View file

@ -1,3 +1,8 @@
/**
* Executes Page.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function Page() {
return (
<div className="w-full h-full flex items-center justify-center text-4xl font-bold">

View file

@ -1,3 +1,8 @@
/**
* Executes Page.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function Page() {
return <div>Home</div>;
}

View file

@ -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 (
<Socket>

View file

@ -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 (
<>

View file

@ -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 (
<div className="w-full h-full flex flex-col gap-10 items-center justify-center">

View file

@ -1,3 +1,8 @@
/**
* Executes Page.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function Page() {
return <div>Signup Page</div>;
}

View file

@ -1,3 +1,8 @@
/**
* Executes getMessages.
* @param none This function has no parameters.
* @returns unknown.
*/
export function getMessages() {
return 0;
}

View file

@ -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<RawMessages>.
*/
export async function getMessages(
send: BoundSendFn<Socket>,
amount: number,

View file

@ -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;

View file

@ -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;

View file

@ -11,8 +11,13 @@ export const context = React.createContext<contextType | undefined>(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) {

View file

@ -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 (
<div className="w-full h-full flex flex-col overflow-hidden px-2">
<div
ref={scrollRef}
id="chat_container"
className="flex-1 max-h-[calc(100vh-151px)] overflow-y-auto px-2.5"
onScroll={() => {
void onScroll();
}}
onScroll={handleContainerScroll}
>
<div
className="relative w-full"

View file

@ -10,11 +10,11 @@
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
"test": "bun test",
"build": "bun run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@noble/curves": "^2.0.1",
"@tensamin/ui": "workspace:*",
"comlink": "^4.4.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"

11
packages/crypto/src/bun-test.d.ts vendored Normal file
View file

@ -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;
};
}

View file

@ -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<string> =>
`${secret}:${plaintext}`,
decrypt: async (secret: string, ciphertext: string): Promise<string> =>
`${secret}|${ciphertext}`,
getSharedSecret: async (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> =>
`${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");
});
});

View file

@ -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<contextType | undefined>(undefined);
type CryptoContextType = {
decrypt: (secret: string, ciphertext: string) => Promise<string>;
encrypt: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
type ApiRef = {
encrypt: (secret: string, plaintext: string) => Promise<string>;
decrypt: (secret: string, ciphertext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
export const context = React.createContext<CryptoContextType | undefined>(
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<ApiRef | null>(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<ApiRef>(worker);
return () => {
apiRef.current = null;
worker.terminate();
setIsWorkerReady(false);
};
}, []);
if (!isWorkerReady) {
return <Loading progress={10} />;
}
return (
<context.Provider value={{ encrypt, decrypt, get_shared_secret }}>
{props.children}
</context.Provider>
);
return <context.Provider value={value}>{props.children}</context.Provider>;
}
type contextType = {
decrypt: (secret: string, data: string) => Promise<string>;
encrypt: (secret: string, data: string) => Promise<string>;
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<string> => {
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<string> => {
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<string>;
};
type ApiRef = {
encrypt: (secret: string, message: string) => Promise<string>;
decrypt: (secret: string, encryptedMessage: string) => Promise<string>;
get_shared_secret: (
own_private_key: string,
own_public_key: string,
other_public_key: string,
) => Promise<string>;
};
export function createCryptoActions(
getApiRef: () => ApiRef | null,
): contextType {
const encrypt = async (secret: string, message: string): Promise<string> => {
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<string> => {
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<string> => {
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");

View file

@ -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);
});
});

View file

@ -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<string> {
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<string> {
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<string> {
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<string> {
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,
});
}

View file

@ -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 <div ref={elementRef} className="tm-md-root" />;
}
/**
* 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<Decoration>[] = [];
const selections = view.state.selection.ranges.map(

View file

@ -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;

View file

@ -10,6 +10,11 @@ export type TextProps = {
value: string;
};
/**
* Executes Text.
* @param props Parameter props.
* @returns unknown.
*/
export default function Text(props: TextProps) {
ensureMarkdownStyles();

View file

@ -2,6 +2,11 @@ import * as React from "react";
export const context = React.createContext<contextType | undefined>(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) {

View file

@ -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,

View file

@ -22,6 +22,11 @@ const StorageContext = React.createContext<StorageContextValue | undefined>(
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<StorageSchema>(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) {

View file

@ -6,6 +6,11 @@ const STORE_NAME = "storage";
let dbPromise: Promise<IDBDatabase> | null = null;
/**
* Executes openDB.
* @param none This function has no parameters.
* @returns Promise<IDBDatabase>.
*/
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
@ -26,6 +31,11 @@ function openDB(): Promise<IDBDatabase> {
return dbPromise;
}
/**
* Executes getEntry.
* @param key Parameter key.
* @returns Promise<StorageSchema[K] | undefined>.
*/
export async function getEntry<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K] | undefined> {
@ -41,6 +51,12 @@ export async function getEntry<K extends keyof StorageSchema>(
});
}
/**
* Executes setEntry.
* @param key Parameter key.
* @param value Parameter value.
* @returns Promise<void>.
*/
export async function setEntry<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
@ -56,6 +72,11 @@ export async function setEntry<K extends keyof StorageSchema>(
});
}
/**
* Executes deleteEntry.
* @param key Parameter key.
* @returns Promise<void>.
*/
export async function deleteEntry<K extends keyof StorageSchema>(
key: K,
): Promise<void> {

View file

@ -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",

11
packages/ttp/src/bun-test.d.ts vendored Normal file
View file

@ -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;
};
}

View file

@ -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<ContextType | undefined>(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<number>(
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<BoundSendFn<Schemas>>(
((
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) {

View file

@ -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<Record<string, unknown>> {
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");
});
});

View file

@ -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<T extends SchemaMap> = {
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<T extends SchemaMap>(
schemas: T,
options: TransportClientOptions = {},
@ -451,11 +466,21 @@ export function createTransportClient<T extends SchemaMap>(
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<T extends SchemaMap>(
}
};
/**
* 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<T extends SchemaMap>(
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<T extends SchemaMap>(
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<T extends SchemaMap>(
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<T extends SchemaMap>(
}
};
/**
* 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<T extends SchemaMap>(
);
};
/**
* 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<T extends SchemaMap>(
})();
};
/**
* 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<T extends SchemaMap>(
});
};
/**
* 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<T extends SchemaMap>(
}
};
/**
* 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<T extends SchemaMap>(
}
};
/**
* 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<T> = ((
type: string,
input?: Record<string, unknown>,
@ -887,6 +967,11 @@ export function createTransportClient<T extends SchemaMap>(
};
}
/**
* 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<string, number>();
@ -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<string, unknown> {
if (!isPlainObject(value)) {
throw new Error("Protocol payload must be a plain object");
@ -972,10 +1087,23 @@ function coercePayload(value: unknown): Record<string, unknown> {
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<string, unknown> {
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<Uint8Array>) {
const payload = await readAll(stream);
if (payload.byteLength < 4) {
@ -1075,6 +1225,11 @@ async function readFrame(stream: ReadableStream<Uint8Array>) {
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<Uint8Array>) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
@ -1105,7 +1260,12 @@ async function readAll(stream: ReadableStream<Uint8Array>) {
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<Record<string, unknown>>,
) {
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<string, unknown>, path: string) {
const normalizedEntries = new Map<
string,
@ -1433,6 +1639,12 @@ function encodeContainerPayload(value: Record<string, unknown>, 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<string, unknown> = {};
@ -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");

View file

@ -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 (
<AvatarPrimitive.Image
@ -36,6 +54,17 @@ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
);
}
/**
* Executes AvatarFallback.
* @param {
className,
...props
} Parameter {
className,
...props
}.
* @returns unknown.
*/
function AvatarFallback({
className,
...props
@ -52,6 +81,11 @@ function AvatarFallback({
);
}
/**
* Executes AvatarBadge.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
@ -68,6 +102,11 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
);
}
/**
* Executes AvatarGroup.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@ -81,6 +120,17 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes AvatarGroupCount.
* @param {
className,
...props
} Parameter {
className,
...props
}.
* @returns unknown.
*/
function AvatarGroupCount({
className,
...props

View file

@ -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",

View file

@ -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 (
<div
@ -33,6 +51,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes CardTitle.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@ -43,6 +66,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes CardDescription.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@ -53,6 +81,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes CardAction.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@ -66,6 +99,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes CardContent.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@ -76,6 +114,11 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
);
}
/**
* Executes CardFooter.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div

View file

@ -3,6 +3,11 @@ import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "../lib/utils";
import { CheckIcon } from "lucide-react";
/**
* Executes Checkbox.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root

View file

@ -4,16 +4,37 @@ import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu
import { cn } from "../lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
/**
* Executes ContextMenu.
* @param { ...props } Parameter { ...props }.
* @returns unknown.
*/
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
/**
* Executes ContextMenuPortal.
* @param { ...props } Parameter { ...props }.
* @returns unknown.
*/
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
/**
* 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 (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
/**
* 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 (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
);
}
/**
* 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<typeof ContextMenuContent>) {
@ -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

View file

@ -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 (
<InputPrimitive

View file

@ -2,6 +2,11 @@ import * as React from "react";
import { cn } from "../lib/utils";
/**
* Executes Label.
* @param { className, ...props } Parameter { className, ...props }.
* @returns unknown.
*/
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -8,6 +8,11 @@ import {
Loader2Icon,
} from "lucide-react";
/**
* Executes Toaster.
* @param { ...props } Parameter { ...props }.
* @returns unknown.
*/
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();

View file

@ -1,6 +1,11 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Executes cn.
* @param inputs Parameter inputs.
* @returns unknown.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -1,5 +1,10 @@
import { ExternalLink } from "lucide-react";
/**
* Executes Link.
* @param props Parameter props.
* @returns unknown.
*/
export default function Link(props: { link: string; label: string }) {
return (
<a

View file

@ -1,5 +1,10 @@
import Link from "../link";
/**
* Executes Screen.
* @param props Parameter props.
* @returns unknown.
*/
export default function Screen(props: { error: string; description: string }) {
return (
<div className="bg-background w-full h-screen flex flex-col justify-center items-center">

View file

@ -9,6 +9,11 @@ type ScreenProps = {
fullscreen?: boolean;
};
/**
* Executes Screen.
* @param props Parameter props.
* @returns unknown.
*/
export default function Screen(props: ScreenProps) {
const [displayProgress, setDisplayProgress] = React.useState(0);
const displayProgressRef = React.useRef(0);
@ -30,6 +35,11 @@ export default function Screen(props: ScreenProps) {
const startTime = performance.now();
let frameId = 0;
/**
* Executes animate.
* @param now Parameter now.
* @returns unknown.
*/
function animate(now: number) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);

View file

@ -22,6 +22,11 @@ const ThemeProviderContext = React.createContext<
ThemeProviderState | undefined
>(undefined);
/**
* Executes isTheme.
* @param value Parameter value.
* @returns value is Theme.
*/
function isTheme(value: string | null): value is Theme {
if (value === null) {
return false;
@ -30,6 +35,11 @@ function isTheme(value: string | null): value is Theme {
return THEME_VALUES.includes(value as Theme);
}
/**
* Executes getSystemTheme.
* @param none This function has no parameters.
* @returns ResolvedTheme.
*/
function getSystemTheme(): ResolvedTheme {
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
return "dark";
@ -38,6 +48,11 @@ function getSystemTheme(): ResolvedTheme {
return "light";
}
/**
* Executes disableTransitionsTemporarily.
* @param none This function has no parameters.
* @returns unknown.
*/
function disableTransitionsTemporarily() {
const style = document.createElement("style");
style.appendChild(
@ -57,6 +72,11 @@ function disableTransitionsTemporarily() {
};
}
/**
* Executes isEditableTarget.
* @param target Parameter target.
* @returns unknown.
*/
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false;
@ -76,6 +96,23 @@ function isEditableTarget(target: EventTarget | null) {
return false;
}
/**
* Executes ThemeProvider.
* @param {
children,
defaultTheme = "system",
storageKey = "theme",
disableTransitionOnChange = true,
...props
} Parameter {
children,
defaultTheme = "system",
storageKey = "theme",
disableTransitionOnChange = true,
...props
}.
* @returns unknown.
*/
export function ThemeProvider({
children,
defaultTheme = "system",
@ -127,6 +164,11 @@ export function ThemeProvider({
}
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
/**
* Executes handleChange.
* @param none This function has no parameters.
* @returns unknown.
*/
const handleChange = () => {
applyTheme("system");
};
@ -139,6 +181,11 @@ export function ThemeProvider({
}, [theme, applyTheme]);
React.useEffect(() => {
/**
* Executes handleKeyDown.
* @param event Parameter event.
* @returns unknown.
*/
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
@ -179,6 +226,11 @@ export function ThemeProvider({
}, [storageKey]);
React.useEffect(() => {
/**
* Executes handleStorageChange.
* @param event Parameter event.
* @returns unknown.
*/
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea !== localStorage) {
return;
@ -218,6 +270,11 @@ export function ThemeProvider({
);
}
/**
* Executes useTheme.
* @param none This function has no parameters.
* @returns unknown.
*/
export const useTheme = () => {
const context = React.useContext(ThemeProviderContext);

View file

@ -12,11 +12,21 @@ interface contextValue {
const UserContext = React.createContext<contextValue | undefined>(undefined);
/**
* Executes UserProvider.
* @param props Parameter props.
* @returns unknown.
*/
export default function UserProvider(props: { children: React.ReactNode }) {
const storageRef = React.useRef<Record<number, User>>({});
const { send } = useSocket();
/**
* Executes get.
* @param userId Parameter userId.
* @returns Promise<User>.
*/
async function get(userId: number): Promise<User> {
if (storageRef.current[userId] === undefined) {
const userData = await send("get_user_data", { user_id: userId });
@ -40,6 +50,11 @@ export default function UserProvider(props: { children: React.ReactNode }) {
);
}
/**
* Executes useUser.
* @param none This function has no parameters.
* @returns contextValue.
*/
export function useUser(): contextValue {
const context = React.useContext(UserContext);
if (!context) {

View file

@ -1,14 +1,22 @@
import * as React from "react";
import { useUser, type User } from "./context";
/**
* Executes Wrapper.
* @param props Parameter props.
* @returns unknown.
*/
export default function Wrapper(props: {
userId: number;
userId?: number;
loading: React.ReactNode;
component: (user: User) => React.ReactNode;
}) {
const { get } = useUser();
const [user, setUser] = React.useState<User | null>(null);
React.useEffect(() => {
if (!props.userId) return;
let active = true;
void get(props.userId)
@ -28,5 +36,5 @@ export default function Wrapper(props: {
};
}, [get, props.userId]);
return <>{user ? props.component(user) : null}</>;
return <>{user ? props.component(user) : props.loading}</>;
}