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

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

View file

@ -2,10 +2,16 @@ import type { User } from "@tensamin/user/context";
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui/cmp/avatar";
import { reduceDisplay } from "./utils";
import { Card, CardHeader } from "@tensamin/ui/cmp/card";
import { Skeleton } from "@tensamin/ui/cmp/skeleton";
export default function Basic(props: { user: User }) {
/**
* Executes Basic.
* @param props Parameter props.
* @returns unknown.
*/
export function Basic(props: { user: User }) {
return (
<Card className="animate-in fade-in duration-300 rounded-2xl">
<Card className="animate-in fade-in duration-300 rounded-2xl py-0">
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
<Avatar>
<AvatarImage src={props.user.avatar} />
@ -18,3 +24,11 @@ export default function Basic(props: { user: User }) {
</Card>
);
}
/**
* Executes Loading.
* @returns unknown.
*/
export function Loading() {
return <Skeleton className="h-12 rounded-xl" />;
}

View file

@ -1,3 +1,8 @@
/**
* Executes reduceDisplay.
* @param display Parameter display.
* @returns unknown.
*/
export function reduceDisplay(display: string) {
const words = display.split(" ");
if (words.length === 1) {

View file

@ -4,11 +4,32 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
import * as React from "react";
import { useUser, type User } from "@tensamin/user/context";
/**
* Navigates to the home route when the navbar home button is clicked.
* @param navigate Router navigate function from TanStack Router.
* @returns Void.
*/
function handleHomeButtonClick(navigate: ReturnType<typeof useNavigate>): void {
void navigate({ to: "/" });
}
/**
* Renders the top navigation bar and currently selected conversation user.
* @returns Navbar JSX element.
*/
export default function Navbar() {
const navigate = useNavigate();
const { get } = useUser();
const search = useRouterState({ select: (state) => state.location.search });
/**
* Delegates navbar home button click to navigation helper.
* @returns Void.
*/
const onHomeButtonClick = React.useCallback(() => {
handleHomeButtonClick(navigate);
}, [navigate]);
const [user, setUser] = React.useState<User | null>(null);
const currentId = React.useMemo(
@ -30,13 +51,11 @@ export default function Navbar() {
return (
<div className="w-full h-13.5 flex items-center justify-center">
<Button
onClick={() => {
void navigate({ to: "/" });
}}
className="w-9 h-9 aspect-square p-0 rounded-lg"
onClick={onHomeButtonClick}
className="w-9 h-9 aspect-square rounded-lg"
variant="outline"
>
<House size={18} />
<House className="size-4.5" />
</Button>
<p className="font-medium pl-3 text-md">{user?.display}</p>
<div className="w-full" />

View file

@ -32,11 +32,118 @@ const formSchema = z.object({
private_key: z.string().min(1).max(92),
});
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
} {
if (rawFileContent.length !== 92 || !rawFileContent.includes("::")) {
throw new Error("Invalid file");
}
const [userIdString, privateKey] = rawFileContent.split("::");
const userId = Number(userIdString);
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
return { userId, privateKey };
}
/**
* Renders the login form for file upload and manual credential login.
* @returns Login form JSX.
*/
export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null);
const { save } = useStorage();
const navigate = useNavigate();
/**
* Opens the hidden file input when the upload tile is clicked.
* @returns Void.
*/
const handleUploadTileClick = React.useCallback((): void => {
uploadRef.current?.click();
}, []);
/**
* Handles uploaded .tu files and stores resolved credentials.
* @param event Change event from the hidden file input.
* @returns Promise that resolves when processing has finished.
*/
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
try {
const file = event.currentTarget.files?.[0];
if (!file) {
throw new Error("No file selected");
}
const raw = await file.text();
const parsed = parseTuFileContent(raw);
await save("user_id", parsed.userId);
await save("private_key", parsed.privateKey);
void navigate({ to: "/" });
} catch (error) {
log(0, "Login", "red", error);
toast("error", "Failed to load file");
}
},
[navigate, save],
);
/**
* Handles username and private key login submission.
* @param event Form submit event.
* @returns Promise that resolves after login processing.
*/
const handleCredentialsSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const rawData = Object.fromEntries(formData);
const inputParse = formSchema.safeParse(rawData);
if (!inputParse.success) {
toast("error", "Please enter valid data");
return;
}
try {
const response = await fetch(
`https://omega.tensamin.net/api/get/id/${inputParse.data.username}`,
);
const data = await response.json();
const parse = fetchedUser.safeParse(data);
if (!parse.success) {
log(0, "Login", "red", "Invalid response from server");
toast("error", "Invalid response from server");
return;
}
const user = parse.data;
await save("user_id", user.data.user_id);
await save("private_key", inputParse.data.private_key);
void navigate({ to: "/" });
} catch (error) {
log(0, "Login", "red", error);
toast("error", "Failed to fetch user data");
}
},
[navigate, save],
);
return (
<div className="flex gap-5">
<Card className="w-75 h-80">
@ -45,35 +152,13 @@ export default function Form() {
</CardHeader>
<CardContent className="h-full flex items-center justify-center">
<div
onClick={() => uploadRef.current?.click()}
onClick={handleUploadTileClick}
className="cursor-pointer w-60 aspect-square mb-17 bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-dotted border-input/75 border-3 flex items-center justify-center rounded-lg"
>
<Upload className="text-input/75" size={34} />
</div>
<input
onChange={async (e) => {
try {
const file = e.currentTarget.files?.[0];
if (file) {
const raw = await file.text();
if (raw.length !== 92) throw new Error("Invalid file");
if (!raw.includes("::")) throw new Error("Invalid file");
const [userIdString, privateKey] = raw.split("::");
const userId = Number(userIdString);
if (!userId || !privateKey) throw new Error("Invalid file");
save("user_id", userId);
save("private_key", privateKey);
void navigate({ to: "/" });
} else {
throw new Error("No file selected");
}
} catch (err) {
log(0, "Login", "red", err);
toast("error", "Failed to load file");
}
}}
onChange={handleFileInputChange}
type="file"
ref={uploadRef}
className="hidden"
@ -87,44 +172,7 @@ export default function Form() {
<CardContent>
<form
className="flex flex-col gap-5 h-full"
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const rawData = Object.fromEntries(formData);
const inputParse = formSchema.safeParse(rawData);
if (!inputParse.success) {
toast("error", "Please enter valid data");
return;
}
const response = await fetch(
"https://omega.tensamin.net/api/get/id/" +
inputParse.data.username,
);
response
.json()
.then((data) => {
const parse = fetchedUser.safeParse(data);
if (parse.success) {
const user = parse.data;
save("user_id", user.data.user_id);
save("private_key", inputParse.data.private_key);
void navigate({ to: "/" });
} else {
log(0, "Login", "red", "Invalid response from server");
toast("error", "Invalid response from server");
}
})
.catch((err) => {
log(0, "Login", "red", err);
toast("error", "Failed to fetch user data");
});
}}
onSubmit={handleCredentialsSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>

View file

@ -1,11 +1,33 @@
import { useStorage } from "@tensamin/storage/context";
import Wrapper from "@tensamin/user/wrapper";
import { type User } from "@tensamin/user/context";
import * as React from "react";
import Basic from "./modals/basic";
import { Basic, Loading } from "./modals/basic";
import List from "@/features/conversation/list/body";
/**
* Renders sidebar user summary content for the current user.
* @param user Loaded user data.
* @returns Sidebar user card JSX.
*/
function renderSidebarUser(user: User): React.ReactNode {
return <Basic user={user} />;
}
/**
* Renders sidebar user summary content skeleton while loading user data.
* @returns Sidebar user card skeleton JSX.
*/
function renderSidebarUserLoading(): React.ReactNode {
return <Loading />;
}
/**
* Renders the conversation sidebar with account summary and conversation list.
* @returns Sidebar JSX.
*/
export default function Sidebar() {
const [userId, setUserId] = React.useState(0);
const [userId, setUserId] = React.useState<undefined | number>(undefined);
const { load } = useStorage();
React.useEffect(() => {
@ -14,9 +36,11 @@ export default function Sidebar() {
return (
<div className="w-75 h-full flex flex-col gap-3 p-2">
{userId !== 0 && (
<Wrapper userId={userId} component={(user) => <Basic user={user} />} />
)}
<Wrapper
loading={renderSidebarUserLoading()}
userId={userId}
component={renderSidebarUser}
/>
<div className="h-full">
<List />
</div>

View file

@ -16,6 +16,11 @@ const ConversationContext = React.createContext<contextValue | undefined>(
undefined,
);
/**
* Executes ConversationProvider.
* @param props Parameter props.
* @returns unknown.
*/
export default function ConversationProvider(props: {
children: React.ReactNode;
}) {
@ -64,6 +69,11 @@ export default function ConversationProvider(props: {
);
}
/**
* Executes useConversation.
* @param none This function has no parameters.
* @returns contextValue.
*/
export function useConversation(): contextValue {
const context = React.useContext(ConversationContext);
if (!context) {

View file

@ -6,6 +6,11 @@ import Switch from "./switch";
import ConversationModal from "../modal/conversation";
import CommunityModal from "../modal/community";
/**
* Executes List.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function List() {
const [category, setCategory] = React.useState<
"conversations" | "communities"

View file

@ -2,6 +2,11 @@ import * as React from "react";
type Category = "conversations" | "communities";
/**
* Executes Switch.
* @param props Parameter props.
* @returns unknown.
*/
export default function Switch(props: {
category: Category;
setCategory: (category: Category) => void;
@ -28,6 +33,11 @@ export default function Switch(props: {
updateIndicator();
}, [updateIndicator]);
/**
* Executes toggleCategory.
* @param none This function has no parameters.
* @returns unknown.
*/
function toggleCategory() {
props.setCategory(
props.category === "conversations" ? "communities" : "conversations",

View file

@ -1,5 +1,10 @@
import type { Community } from "@tensamin/shared/features/conversation/schema";
/**
* Executes CommunityModal.
* @param props Parameter props.
* @returns unknown.
*/
export default function CommunityModal(props: { community: Community }) {
return <div>{props.community.community_title}</div>;
}

View file

@ -1,4 +1,4 @@
import Basic from "@/components/modals/basic";
import { Basic, Loading } from "@/components/modals/basic";
import Wrapper from "@tensamin/user/wrapper";
import {
ContextMenu,
@ -8,22 +8,52 @@ import {
ContextMenuTrigger,
} from "@tensamin/ui/cmp/context-menu";
import { useNavigate } from "@tanstack/react-router";
import { type User } from "@tensamin/user/context";
/**
* Renders the conversation modal user preview card.
* @param user Loaded user data.
* @returns Conversation preview card JSX.
*/
function renderConversationUser(user: User) {
return <Basic user={user} />;
}
/**
* Renders the conversation modal user preview card skeleton while loading user data.
* @returns Conversation preview card skeleton JSX.
*/
function renderConversationUserLoading() {
return <Loading />;
}
/**
* Renders a conversation context-menu entry for a specific user.
* @param props Component props with selected user id.
* @returns Conversation modal trigger and menu JSX.
*/
export default function ConversationModal(props: { userId: number }) {
const navigate = useNavigate();
/**
* Navigates to the selected conversation in chat view.
* @returns Void.
*/
const onConversationClick = () => {
void navigate({ to: "/chat", search: { id: props.userId } });
};
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className="select-none cursor-pointer"
onClick={() => {
void navigate({ to: "/chat", search: { id: props.userId } });
}}
onClick={onConversationClick}
>
<Wrapper
loading={renderConversationUserLoading()}
userId={props.userId}
component={(user) => <Basic user={user} />}
component={renderConversationUser}
/>
</div>
</ContextMenuTrigger>

View file

@ -11,6 +11,60 @@ import { log } from "@tensamin/shared/log";
import Link from "@tensamin/ui/link";
import { Label } from "@tensamin/ui/cmp/label";
type SaveFn = ReturnType<typeof useStorage>["save"];
/**
* Persists accepted legal documents and marks the first onboarding step complete.
* @param save Storage save function.
* @param currentDocs Current legal documents fetched from the server.
* @param setPPandToSDone State setter for legal acceptance completion.
* @returns Promise that resolves when persistence is complete.
*/
async function persistAcceptedDocs(
save: SaveFn,
currentDocs: z.infer<typeof legalDocsSchema>,
setPPandToSDone: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
await save("accepted_privacy_policy", true);
await save("accepted_terms_of_service", true);
await save("ppandtos_done", true);
await save("legal_docs", currentDocs);
setPPandToSDone(true);
}
/**
* Persists analytics preference toggles and marks analytics onboarding complete.
* @param save Storage save function.
* @param crashReports Whether crash reports are enabled.
* @param usageData Whether usage data is enabled.
* @param setCrashReports State setter for crash reports.
* @param setUsageData State setter for usage data.
* @param setDoneWithAnalytics State setter for analytics completion.
* @returns Promise that resolves when persistence is complete.
*/
async function persistAnalyticsPreferences(
save: SaveFn,
crashReports: boolean,
usageData: boolean,
setCrashReports: React.Dispatch<React.SetStateAction<boolean>>,
setUsageData: React.Dispatch<React.SetStateAction<boolean>>,
setDoneWithAnalytics: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
await save("analytics_crash_reports", crashReports);
setCrashReports(crashReports);
await save("analytics_usage_data", usageData);
setUsageData(usageData);
await save("analytics_done", true);
setDoneWithAnalytics(true);
}
/**
* Gates the application behind legal and analytics consent checks.
* @param props Component props containing children to render after consent.
* @returns Legal onboarding or wrapped children JSX.
*/
export default function Screen(props: { children: React.ReactNode }) {
const { load, save } = useStorage();
@ -31,6 +85,37 @@ export default function Screen(props: { children: React.ReactNode }) {
const [crashReports, setCrashReports] = React.useState(false);
const [usageData, setUsageData] = React.useState(false);
/**
* Handles continue action for privacy policy and terms acceptance.
* @returns Void.
*/
const handleContinueLegal = React.useCallback((): void => {
const currentDocs = remoteDocs;
if (!currentDocs) {
return;
}
void persistAcceptedDocs(save, currentDocs, setPPandToSDone);
}, [remoteDocs, save]);
/**
* Handles continue action for analytics preferences.
* @returns Void.
*/
const handleContinueAnalytics = React.useCallback((): void => {
const currentCrashReports = crashReports;
const currentUsageData = usageData;
void persistAnalyticsPreferences(
save,
currentCrashReports,
currentUsageData,
setCrashReports,
setUsageData,
setDoneWithAnalytics,
);
}, [crashReports, save, usageData]);
React.useEffect(() => {
let active = true;
@ -178,20 +263,7 @@ export default function Screen(props: { children: React.ReactNode }) {
</div>
<ContinueButton
disabled={!acceptedPP || !acceptedTOS}
onClick={() => {
const currentDocs = remoteDocs;
if (!currentDocs) {
return;
}
void (async () => {
await save("accepted_privacy_policy", true);
await save("accepted_terms_of_service", true);
await save("ppandtos_done", true);
await save("legal_docs", currentDocs);
setPPandToSDone(true);
})();
}}
onClick={handleContinueLegal}
/>
</>
) : (
@ -211,24 +283,7 @@ export default function Screen(props: { children: React.ReactNode }) {
/>
</div>
</div>
<ContinueButton
onClick={() => {
const currentCrashReports = crashReports;
const currentUsageData = usageData;
void save("analytics_crash_reports", currentCrashReports).then(
() => {
setCrashReports(currentCrashReports);
},
);
void save("analytics_usage_data", currentUsageData).then(() => {
setUsageData(currentUsageData);
});
void save("analytics_done", true).then(() => {
setDoneWithAnalytics(true);
});
}}
/>
<ContinueButton onClick={handleContinueAnalytics} />
</>
)}
</div>
@ -236,6 +291,11 @@ export default function Screen(props: { children: React.ReactNode }) {
);
}
/**
* Renders a large continue button used by legal and analytics steps.
* @param props Button props with click callback and disabled state.
* @returns Continue button JSX.
*/
function ContinueButton(props: { onClick: () => void; disabled?: boolean }) {
return (
<div className="w-full flex justify-end">
@ -251,6 +311,11 @@ function ContinueButton(props: { onClick: () => void; disabled?: boolean }) {
);
}
/**
* Renders a larger checkbox row for onboarding preferences.
* @param props Checkbox label, current value, and change callback.
* @returns Checkbox row JSX.
*/
export function BigCheckbox(props: {
label: string;
checked: boolean;

View file

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

View file

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

View file

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

View file

@ -6,6 +6,11 @@ import Sidebar from "@/components/sidebar";
import Conversation from "@/features/conversation/context";
import Navbar from "@/components/navbar";
/**
* Executes Layout.
* @param props Parameter props.
* @returns unknown.
*/
export default function Layout(props: { children: ReactNode }) {
return (
<Socket>

View file

@ -7,6 +7,11 @@ import LegalWrapper from "@/features/legal/screen";
import { Toaster } from "@tensamin/ui/cmp/sonner";
/**
* Executes Layout.
* @param props Parameter props.
* @returns unknown.
*/
export default function Layout(props: { children: ReactNode }) {
return (
<>

View file

@ -2,6 +2,11 @@ import Form from "@/components/screens/login/form";
import { Button } from "@tensamin/ui/cmp/button";
import { Link } from "@tanstack/react-router";
/**
* Executes Page.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function Page() {
return (
<div className="w-full h-full flex flex-col gap-10 items-center justify-center">

View file

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