diff --git a/apps/web/src/components/screens/login/form.tsx b/apps/web/src/components/screens/login/form.tsx index 3bb2c5a..b839222 100644 --- a/apps/web/src/components/screens/login/form.tsx +++ b/apps/web/src/components/screens/login/form.tsx @@ -63,14 +63,6 @@ export default function Form() { 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. @@ -105,7 +97,7 @@ export default function Form() { * @returns Promise that resolves after login processing. */ const handleCredentialsSubmit = React.useCallback( - async (event: React.FormEvent): Promise => { + async (event: React.SubmitEvent): Promise => { event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -135,7 +127,7 @@ export default function Form() { await save("user_id", user.data.user_id); await save("private_key", inputParse.data.private_key); - void navigate({ to: "/" }); + navigate({ to: "/" }); } catch (error) { log(0, "Login", "red", error); toast("error", "Failed to fetch user data"); @@ -145,47 +137,37 @@ export default function Form() { ); return ( -
- - - Use .tu file - - -
- -
- -
-
- - - Use credentials - - -
-
- - -
-
- - -
- -
-
-
+
+
uploadRef.current?.click()} + className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg" + > + +

Upload .tu file

+
+ +
+
+ + +
+
+ + +
+ +
); } diff --git a/apps/web/src/features/legal/screen.tsx b/apps/web/src/features/legal/screen.tsx index 4551bce..1ea3367 100644 --- a/apps/web/src/features/legal/screen.tsx +++ b/apps/web/src/features/legal/screen.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import { useState, useCallback, useEffect } from "react"; import { useStorage } from "@tensamin/storage/context"; import { Button } from "@tensamin/ui/cmp/button"; import { Checkbox } from "@tensamin/ui/cmp/checkbox"; @@ -11,112 +11,39 @@ import { log } from "@tensamin/shared/log"; import Link from "@tensamin/ui/link"; import { Label } from "@tensamin/ui/cmp/label"; -type SaveFn = ReturnType["save"]; - -/** - * Persists accepted legal documents and marks the first onboarding step complete. - * @param save Storage save function. - * @param currentDocs Current legal documents fetched from the server. - * @param setPPandToSDone State setter for legal acceptance completion. - * @returns Promise that resolves when persistence is complete. - */ -async function persistAcceptedDocs( - save: SaveFn, - currentDocs: z.infer, - setPPandToSDone: React.Dispatch>, -): Promise { - await save("accepted_privacy_policy", true); - await save("accepted_terms_of_service", true); - await save("ppandtos_done", true); - await save("legal_docs", currentDocs); - setPPandToSDone(true); -} - -/** - * Persists analytics preference toggles and marks analytics onboarding complete. - * @param save Storage save function. - * @param crashReports Whether crash reports are enabled. - * @param usageData Whether usage data is enabled. - * @param setCrashReports State setter for crash reports. - * @param setUsageData State setter for usage data. - * @param setDoneWithAnalytics State setter for analytics completion. - * @returns Promise that resolves when persistence is complete. - */ -async function persistAnalyticsPreferences( - save: SaveFn, - crashReports: boolean, - usageData: boolean, - setCrashReports: React.Dispatch>, - setUsageData: React.Dispatch>, - setDoneWithAnalytics: React.Dispatch>, -): Promise { - await save("analytics_crash_reports", crashReports); - setCrashReports(crashReports); - - await save("analytics_usage_data", usageData); - setUsageData(usageData); - - await save("analytics_done", true); - setDoneWithAnalytics(true); -} - -/** - * Gates the application behind legal and analytics consent checks. - * @param props Component props containing children to render after consent. - * @returns Legal onboarding or wrapped children JSX. - */ +// Prevents the user from using Tensamin without accepting the privacy policy and terms of service. export default function Screen(props: { children: React.ReactNode }) { const { load, save } = useStorage(); - const [error, setError] = React.useState(""); - const [errorDescription, setErrorDescription] = React.useState(""); + const [error, setError] = useState(""); + const [errorDescription, setErrorDescription] = useState(""); - const [remoteDocs, setRemoteDocs] = React.useState< + const [remoteDocs, setRemoteDocs] = useState< z.infer | undefined >(undefined); - const [loading, setLoading] = React.useState(true); + const [loading, setLoading] = useState(true); - const [PPandToSDone, setPPandToSDone] = React.useState(false); - const [acceptedPP, acceptPP] = React.useState(false); - const [acceptedTOS, acceptTOS] = React.useState(false); + const [acceptedPP, acceptPP] = useState(false); + const [acceptedTOS, acceptTOS] = useState(false); + const [hasContinued, setHasContinued] = useState(false); - const [doneWithAnalytics, setDoneWithAnalytics] = React.useState(false); - const [crashReports, setCrashReports] = React.useState(false); - const [usageData, setUsageData] = React.useState(false); + const [userId, setUserId] = useState(undefined); - /** - * Handles continue action for privacy policy and terms acceptance. - * @returns Void. - */ - const handleContinueLegal = React.useCallback((): void => { + // Saves + const handleContinueLegal = useCallback((): void => { const currentDocs = remoteDocs; if (!currentDocs) { return; } - void persistAcceptedDocs(save, currentDocs, setPPandToSDone); + save("accepted_privacy_policy", true); + save("accepted_terms_of_service", true); + save("legal_docs", currentDocs); + setHasContinued(true); }, [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(() => { + useEffect(() => { let active = true; void load("user_id").then(async (id) => { @@ -124,9 +51,9 @@ export default function Screen(props: { children: React.ReactNode }) { return; } + setUserId(id); + if (id === 0) { - setPPandToSDone(true); - setDoneWithAnalytics(true); setLoading(false); return; } @@ -170,41 +97,24 @@ export default function Screen(props: { children: React.ReactNode }) { const localDocs = await load("legal_docs"); - const [ - loadedPPAndTOS, - loadedAcceptedPP, - loadedAcceptedTOS, - loadedAnalyticsDone, - loadedCrashReports, - loadedUsageData, - ] = await Promise.all([ - load("ppandtos_done"), + const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([ load("accepted_privacy_policy"), load("accepted_terms_of_service"), - load("analytics_done"), - load("analytics_crash_reports"), - load("analytics_usage_data"), ]); if (!active) { return; } - setPPandToSDone(loadedPPAndTOS); acceptPP(loadedAcceptedPP); acceptTOS(loadedAcceptedTOS); - setDoneWithAnalytics(loadedAnalyticsDone); - setCrashReports(loadedCrashReports); - setUsageData(loadedUsageData); if (localDocs.pp.hash !== safeCurrent.data.pp.hash) { acceptPP(false); - setPPandToSDone(false); } if (localDocs.tos.hash !== safeCurrent.data.tos.hash) { acceptTOS(false); - setPPandToSDone(false); } setLoading(false); @@ -219,91 +129,70 @@ export default function Screen(props: { children: React.ReactNode }) { return ; } - if (loading) { + if (loading || userId === undefined) { return null; } - if (PPandToSDone && doneWithAnalytics) { + if ((acceptedPP && acceptedTOS && hasContinued) || userId === 0) { return <>{props.children}; } return (
- {!PPandToSDone ? ( - <> -

- Privacy Policy & ToS -

- {remoteDocs?.pp.version} / {remoteDocs?.tos.version} -

-

-
-
- - -
- - -
-
- + Privacy Policy & ToS +

+ {remoteDocs?.pp.version} / {remoteDocs?.tos.version} +

+ +
+
+ - - ) : ( - <> -

Analytics

-
-
- - -
-
- - - )} + +
+ + +
+
+
); } -/** - * 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 }) { +// Components +function ContinueButton({ + onClick, + disabled, +}: { + onClick: () => void; + disabled?: boolean; +}) { return (
@@ -311,12 +200,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: { +export function BigCheckbox({ + label, + checked, + onChange, +}: { label: string; checked: boolean; onChange: (checked: boolean) => void; @@ -324,11 +212,11 @@ export function BigCheckbox(props: { return (
- +
); } diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index f637b46..dcd20e3 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -1,10 +1,12 @@ import Socket from "@tensamin/ttp/context"; import User from "@tensamin/user/context"; -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; +import { useNavigate, useLocation } from "@tanstack/react-router"; import Sidebar from "@/components/sidebar"; import Conversation from "@/features/conversation/context"; import Navbar from "@/components/navbar"; +import { useStorage } from "@tensamin/storage/context"; /** * Executes Layout. @@ -12,8 +14,30 @@ import Navbar from "@/components/navbar"; * @returns unknown. */ export default function Layout(props: { children: ReactNode }) { + const [loggedIn, setLoggedIn] = useState(null); + + const { load } = useStorage(); + + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + console.log(location); + + load("user_id").then((userId) => { + if (userId !== 0) { + setLoggedIn(true); + } else { + navigate({ + to: "/login", + }); + setLoggedIn(false); + } + }); + }, [location]); + return ( - +
diff --git a/apps/web/src/routes/screens/login.tsx b/apps/web/src/routes/screens/login.tsx index 65a6659..25fcccd 100644 --- a/apps/web/src/routes/screens/login.tsx +++ b/apps/web/src/routes/screens/login.tsx @@ -1,6 +1,4 @@ import Form from "@/components/screens/login/form"; -import { Button } from "@tensamin/ui/cmp/button"; -import { Link } from "@tanstack/react-router"; /** * Executes Page. @@ -12,9 +10,12 @@ export default function Page() { ); diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index 8f3f6a5..d9dbc3e 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -1,4 +1,13 @@ -import * as React from "react"; +import { + useState, + createContext, + type ReactNode, + useRef, + useEffect, + useContext, + useCallback, + useMemo, +} from "react"; import { useCrypto } from "@tensamin/crypto/context"; import { log } from "@tensamin/shared/log"; import { useStorage } from "@tensamin/storage/context"; @@ -129,34 +138,35 @@ type ContextType = { identified: () => boolean; }; -const socketContext = React.createContext(undefined); +const socketContext = createContext(undefined); /** * Provides socket transport state and authenticated send operations to children. * @param props Component props with children. * @returns Loading, error, or provider-wrapped JSX. */ -export default function Provider(props: { children: React.ReactNode }) { +export default function Provider(props: { + children: ReactNode; + blockConnection?: boolean; +}) { const { load } = useStorage(); const { decrypt, getSharedSecret } = useCrypto(); - const [readyState, setReadyState] = React.useState( - READY_STATE.CLOSED, - ); - const [connected, setConnected] = React.useState(false); - const [identified, setIdentified] = React.useState(false); - const [identifying, setIdentifying] = React.useState(false); + const [readyState, setReadyState] = useState(READY_STATE.CLOSED); + const [connected, setConnected] = useState(false); + const [identified, setIdentified] = useState(false); + const [identifying, setIdentifying] = useState(false); - const [ownPing, setOwnPing] = React.useState(0); - const [iotaPing, setIotaPing] = React.useState(0); + const [ownPing, setOwnPing] = useState(0); + const [iotaPing, setIotaPing] = useState(0); - const [error, setError] = React.useState(""); - const [errorDescription, setErrorDescription] = React.useState(""); + const [error, setError] = useState(""); + const [errorDescription, setErrorDescription] = useState(""); - const clientRef = React.useRef > | null>(null); - const identificationStartedRef = React.useRef(false); + const identificationStartedRef = useRef(false); /** * Sends typed protocol messages through the active transport client. @@ -165,7 +175,7 @@ export default function Provider(props: { children: React.ReactNode }) { * @param options Optional request id and response mode. * @returns A promise for either void (no response) or typed message payload. */ - const send = React.useCallback>( + const send = useCallback>( (( type: string, data?: Record, @@ -192,7 +202,7 @@ export default function Provider(props: { children: React.ReactNode }) { [], ); - React.useEffect(() => { + useEffect(() => { if (!connected || !identified) { return; } @@ -222,7 +232,7 @@ export default function Provider(props: { children: React.ReactNode }) { }; }, [connected, identified, send]); - React.useEffect(() => { + useEffect(() => { let attempts = 0; let reconnectTimer: ReturnType | null = null; let reconnectResetTimer: ReturnType | null = null; @@ -296,43 +306,45 @@ export default function Provider(props: { children: React.ReactNode }) { }, RETRY_INTERVAL); }; - const transportClient = createTransportClient(schemas, { - url: TRANSPORT_URL, - onReadyStateChange: (state) => { - setReadyState(state); + const transportClient = !props.blockConnection + ? createTransportClient(schemas, { + url: TRANSPORT_URL, + onReadyStateChange: (state) => { + setReadyState(state); - if (state === READY_STATE.OPEN) { - clearReconnectTimer(); - scheduleReconnectReset(); - identificationStartedRef.current = false; - setConnected(true); - setIdentified(false); - setError(""); - setErrorDescription(""); - return; - } + if (state === READY_STATE.OPEN) { + clearReconnectTimer(); + scheduleReconnectReset(); + identificationStartedRef.current = false; + setConnected(true); + setIdentified(false); + setError(""); + setErrorDescription(""); + return; + } - clearReconnectResetTimer(); - identificationStartedRef.current = false; - setConnected(false); - setIdentified(false); - }, - onClose: ({ error: closeError, intentional }) => { - clearReconnectResetTimer(); - setConnected(false); - setIdentified(false); - setIdentifying(false); + clearReconnectResetTimer(); + identificationStartedRef.current = false; + setConnected(false); + setIdentified(false); + }, + onClose: ({ error: closeError, intentional }) => { + clearReconnectResetTimer(); + setConnected(false); + setIdentified(false); + setIdentifying(false); - if (disposed || intentional) { - return; - } + if (disposed || intentional) { + return; + } - log(0, "Socket", "red", "Disconnected", closeError, { - stopSending: isStopSendingError(closeError), - }); - scheduleReconnect(closeError); - }, - }); + log(0, "Socket", "red", "Disconnected", closeError, { + stopSending: isStopSendingError(closeError), + }); + scheduleReconnect(closeError); + }, + }) + : null; clientRef.current = transportClient; @@ -346,7 +358,7 @@ export default function Provider(props: { children: React.ReactNode }) { } try { - await transportClient.connect(TRANSPORT_URL); + await transportClient?.connect(TRANSPORT_URL); } catch (connectError) { if (disposed) { return; @@ -368,7 +380,7 @@ export default function Provider(props: { children: React.ReactNode }) { clientRef.current = null; } - void transportClient.close("context-dispose"); + void transportClient?.close("context-dispose"); setReadyState(READY_STATE.CLOSED); setConnected(false); setIdentified(false); @@ -377,7 +389,7 @@ export default function Provider(props: { children: React.ReactNode }) { }; }, []); - React.useEffect(() => { + useEffect(() => { if (!connected) { setIdentifying(false); setIdentified(false); @@ -489,7 +501,7 @@ export default function Provider(props: { children: React.ReactNode }) { }; }, [connected, decrypt, getSharedSecret, load, send]); - const progress = React.useMemo(() => { + const progress = useMemo(() => { if (readyState === READY_STATE.CONNECTING) return 30; if (!connected) return 45; if (identifying) return 75; @@ -497,7 +509,7 @@ export default function Provider(props: { children: React.ReactNode }) { return 100; }, [connected, identified, identifying, readyState]); - const loadingTitle = React.useMemo(() => { + const loadingTitle = useMemo(() => { if (readyState === READY_STATE.CONNECTING || !connected) { return "Connecting to Tensamin"; } @@ -509,7 +521,7 @@ export default function Provider(props: { children: React.ReactNode }) { return "Loading"; }, [connected, identified, identifying, readyState]); - const loadingDescription = React.useMemo(() => { + const loadingDescription = useMemo(() => { if (readyState === READY_STATE.CONNECTING || !connected) { return "Establishing transport channel"; } @@ -521,7 +533,7 @@ export default function Provider(props: { children: React.ReactNode }) { return undefined; }, [connected, identified, identifying, readyState]); - const contextValue = React.useMemo( + const contextValue = useMemo( () => ({ send, readyState: () => readyState, @@ -559,7 +571,7 @@ export default function Provider(props: { children: React.ReactNode }) { * @returns Socket context API for transport operations and connection state. */ export function useSocket(): ContextType { - const context = React.useContext(socketContext); + const context = useContext(socketContext); if (!context) { throw new Error("useSocket must be used within a SocketProvider"); } diff --git a/todo.md b/todo.md index 8808773..bba9d6e 100644 --- a/todo.md +++ b/todo.md @@ -1 +1,3 @@ - Calculate message height and pass to virtualizer (-> packages/chat/src/components/input.tsx) +- Review legal screen stuff +- Fix that location change doesn't trigger loggedIn so the user is stuck on a connecting page