Big Updated, added some tests, added comments for all functions, I forgot the rest
This commit is contained in:
parent
d77271e4b7
commit
90a1059cc8
59 changed files with 1816 additions and 203 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* Executes Page.
|
||||
* @param none This function has no parameters.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Page() {
|
||||
return <div>Home</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* Executes Page.
|
||||
* @param none This function has no parameters.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Page() {
|
||||
return <div>Signup Page</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* Executes getMessages.
|
||||
* @param none This function has no parameters.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export function getMessages() {
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
11
packages/crypto/src/bun-test.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
46
packages/crypto/src/context.test.ts
Normal file
46
packages/crypto/src/context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
88
packages/crypto/src/worker.test.ts
Normal file
88
packages/crypto/src/worker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
/**
|
||||
* @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,
|
||||
get_shared_secret,
|
||||
});
|
||||
getSharedSecret,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ export type TextProps = {
|
|||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes Text.
|
||||
* @param props Parameter props.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Text(props: TextProps) {
|
||||
ensureMarkdownStyles();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
11
packages/ttp/src/bun-test.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
87
packages/ttp/src/core.test.ts
Normal file
87
packages/ttp/src/core.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
packages/ui/src/cmp/skeleton.tsx
Normal file
13
packages/ui/src/cmp/skeleton.tsx
Normal 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 };
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue