From fd15215f1574eef923688d9f68a30f88e46b83c4 Mon Sep 17 00:00:00 2001 From: Alois Date: Fri, 1 May 2026 16:40:16 +0200 Subject: [PATCH] (feat): add cache for conversations and communities (feat): conversations now get moved to the top when engaged with (qol): update todo --- apps/web/src/components/navbar.tsx | 4 +- .../src/features/conversation/list/body.tsx | 9 +- apps/web/src/index.tsx | 33 ++++---- bun.lock | 1 + .../call/src/components/buttons/invite.tsx | 4 +- packages/chat/src/components/input.tsx | 4 + packages/chat/src/context.tsx | 57 +++++++------ packages/notifications/src/context.tsx | 5 ++ packages/shared/src/data.ts | 11 +++ packages/storage/package.json | 2 + packages/storage/src/session.tsx | 84 +++++++++++++++++++ packages/storage/todo.md | 1 + packages/ttp/src/context.tsx | 26 +++--- scripts/copy-licenses.ts | 4 +- 14 files changed, 178 insertions(+), 67 deletions(-) create mode 100644 packages/storage/src/session.tsx create mode 100644 packages/storage/todo.md diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index ed2dad0..3499547 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -8,13 +8,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui"; import { displayCallId } from "@tensamin/call/utils"; import { useState } from "react"; import { SidebarTrigger, useSidebar } from "@tensamin/ui"; -import { useTTP } from "@tensamin/ttp"; import { WindowControls as Controls } from "@tensamin/ui"; +import { useSession } from "@tensamin/storage/session"; export default function Navbar({ forMobile }: { forMobile: boolean }) { const navigate = useNavigate(); const callState = useCall((state) => state.state); - const { contacts } = useTTP(); + const { contacts } = useSession(); const { pathname } = useLocation(); diff --git a/apps/web/src/features/conversation/list/body.tsx b/apps/web/src/features/conversation/list/body.tsx index ac68e4c..7092b6f 100644 --- a/apps/web/src/features/conversation/list/body.tsx +++ b/apps/web/src/features/conversation/list/body.tsx @@ -1,23 +1,18 @@ import * as React from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTTP } from "@tensamin/ttp"; import Switch from "./switch"; import ConversationModal from "../modal/conversation"; import CommunityModal from "../modal/community"; import { Loader2 } from "lucide-react"; +import { useSession } from "@tensamin/storage/session"; -/** - * Executes List. - * @param none This function has no parameters. - * @returns unknown. - */ export default function List() { const [category, setCategory] = React.useState< "conversations" | "communities" >("conversations"); - const { contacts, communities } = useTTP(); + const { contacts, communities } = useSession(); const items = category === "conversations" ? contacts : communities; const scrollRef = React.useRef(null); diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 01fd1af..34d63a9 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -22,7 +22,7 @@ import Login from "@/routes/screens/login"; import ChatContext from "@tensamin/chat/context"; import { useInitializeCall } from "@tensamin/call/store"; -import { Provider as SocketContext } from "@tensamin/ttp"; +import { Provider as TTPProvider } from "@tensamin/ttp"; import UserContext from "@tensamin/user/context"; import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; import NotificationsProvider from "@tensamin/notifications/context"; @@ -35,6 +35,7 @@ import z from "zod"; import { useEffect, useState, type ReactNode } from "react"; import Storage from "@tensamin/storage/context"; +import Session from "@tensamin/storage/session"; import Crypto from "@tensamin/crypto/context"; import Mobile from "@tensamin/tauri/context"; @@ -141,20 +142,22 @@ function RootShell() { function AppShell() { return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/bun.lock b/bun.lock index 70f4ecb..5ac210b 100644 --- a/bun.lock +++ b/bun.lock @@ -197,6 +197,7 @@ "version": "0.0.0", "dependencies": { "@tensamin/shared": "workspace:*", + "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/packages/call/src/components/buttons/invite.tsx b/packages/call/src/components/buttons/invite.tsx index 2e1538e..42fafff 100644 --- a/packages/call/src/components/buttons/invite.tsx +++ b/packages/call/src/components/buttons/invite.tsx @@ -1,9 +1,9 @@ -import { useTTP } from "@tensamin/ttp"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import Wrapper from "@tensamin/user/wrapper"; import { Mail } from "lucide-react"; import { sendCallInvite } from "../../store"; import { log, toast } from "@tensamin/shared/log"; +import { useSession } from "@tensamin/storage/session"; export default function InviteButton({ className, @@ -12,7 +12,7 @@ export default function InviteButton({ className?: string; iconSize?: number; }) { - const { contacts } = useTTP(); + const { contacts } = useSession(); return ( diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index dde2ad6..66354c8 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -10,6 +10,7 @@ import { useTTP } from "@tensamin/ttp"; import { log, toast } from "@tensamin/shared/log"; import { cn, useIsMobile } from "@tensamin/ui"; import { encryptText } from "@tensamin/crypto/worker"; +import { useSession } from "@tensamin/storage/session"; export default function InputComponent({ value, @@ -23,6 +24,7 @@ export default function InputComponent({ const { send } = useTTP(); const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat(); const { load } = useStorage(); + const { moveUserIdToTop } = useSession(); React.useEffect(() => { void load("settings.reverse_enter_behavior").then((shouldInvert) => { @@ -78,6 +80,8 @@ export default function InputComponent({ toast("error", "Failed to send message"); }); + moveUserIdToTop(userId); + setValue(""); } diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 887fb4c..38ee454 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -17,6 +17,7 @@ import { useUser } from "@tensamin/user/context"; import { useStorage } from "@tensamin/storage/context"; import { useTTP } from "@tensamin/ttp"; import { log } from "@tensamin/shared/log"; +import { useSession } from "@tensamin/storage/session"; export const context = createContext(undefined); @@ -59,6 +60,7 @@ export default function Provider(props: { children: ReactNode }) { const { get } = useUser(); const { load } = useStorage(); const { send, subscribePush } = useTTP(); + const { moveUserIdToTop } = useSession(); const [liveMessagesState, setLiveMessagesState] = useState([]); const [currentSharedSecretState, setCurrentSharedSecretState] = useState<{ @@ -171,32 +173,39 @@ export default function Provider(props: { children: ReactNode }) { [send, userIdValue, currentSharedSecret, decryptText], ); - const addLiveMessage = useCallback((message: RawMessage) => { - const localId = - globalThis.crypto?.randomUUID?.() ?? - `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const addLiveMessage = useCallback( + (message: RawMessage) => { + const localId = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(36).slice(2)}`; - setLiveMessagesState((prev) => [ - ...prev, - { - ...message, - localId, - failed: false, - }, - ]); + if (!message.sent_by_self) { + moveUserIdToTop(userIdValue); + } - return { - setFailed: (failed: boolean) => { - setLiveMessagesState((prev) => - prev.map((liveMessage) => - liveMessage.localId === localId - ? { ...liveMessage, failed } - : liveMessage, - ), - ); - }, - }; - }, []); + setLiveMessagesState((prev) => [ + ...prev, + { + ...message, + localId, + failed: false, + }, + ]); + + return { + setFailed: (failed: boolean) => { + setLiveMessagesState((prev) => + prev.map((liveMessage) => + liveMessage.localId === localId + ? { ...liveMessage, failed } + : liveMessage, + ), + ); + }, + }; + }, + [userIdValue, moveUserIdToTop], + ); const clearLiveMessages = useCallback(() => { setLiveMessagesState([]); diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 7ba0d11..4e433a3 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -9,6 +9,7 @@ import { toast as sonnerToast } from "sonner"; import { message as messageSchema } from "@tensamin/shared/data"; import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui"; import { isTauri } from "@tauri-apps/api/core"; +import { useSession } from "@tensamin/storage/session"; export const context = createContext(undefined); @@ -27,6 +28,7 @@ export default function Provider(props: { children: React.ReactNode }) { const { get } = useUser(); const { decryptText, getSharedSecret } = useCrypto(); const { addLiveMessage, userId } = useChat(); + const { moveUserIdToTop } = useSession(); useEffect(() => { return subscribePush(async (ttpMessage) => { @@ -60,6 +62,8 @@ export default function Provider(props: { children: React.ReactNode }) { // add notification symbol to conversation cards + moveUserIdToTop(sender_id); + if (isTauri()) { console.log("weewoo"); } else { @@ -86,6 +90,7 @@ export default function Provider(props: { children: React.ReactNode }) { get, addLiveMessage, userId, + moveUserIdToTop, ]); return ( diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 3302875..a05afc2 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -39,6 +39,13 @@ export const failedUser = { username: "unknown", } as z.infer; +export type Contacts = z.infer< + typeof ttp.challenge_response.response.shape.contacts +>; +export type Communities = z.infer< + typeof ttp.challenge_response.response.shape.communities +>; + // TTP export const ttp = { identification: { @@ -230,6 +237,8 @@ export interface Storage extends SettingsStorageDefaults { analytics_usage_data: boolean; analytics_done: boolean; legal_docs: z.infer; + cached_contacts: Contacts; + cached_communities: Communities; } export const storageDefaults: Storage = { @@ -260,4 +269,6 @@ export const storageDefaults: Storage = { unix: 0, }, }, + cached_contacts: [], + cached_communities: [], }; diff --git a/packages/storage/package.json b/packages/storage/package.json index 8d24e11..ef28e8b 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "type": "module", "exports": { + "./session": "./src/session.tsx", "./context": "./src/context.tsx", "./indexed-db": "./src/indexed-db.ts" }, @@ -14,6 +15,7 @@ }, "dependencies": { "@tensamin/shared": "workspace:*", + "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/packages/storage/src/session.tsx b/packages/storage/src/session.tsx new file mode 100644 index 0000000..32efc13 --- /dev/null +++ b/packages/storage/src/session.tsx @@ -0,0 +1,84 @@ +import { useTTP } from "@tensamin/ttp"; +import { + createContext, + type ReactNode, + useState, + useContext, + useEffect, +} from "react"; +import { useStorage } from "./context"; +import type { Contacts, Communities } from "@tensamin/shared/data"; + +interface SessionContextType { + contacts: Contacts; + communities: Communities; + moveUserIdToTop: (userId: number) => void; +} + +const SessionContext = createContext(undefined); + +export default function SessionProvider({ children }: { children: ReactNode }) { + const { freshContacts, freshCommunities } = useTTP(); + const { load, save } = useStorage(); + const [contacts, setContacts] = useState([]); + const [communities, setCommunities] = useState([]); + + // Get cached data and merge fresh data + useEffect(() => { + load("cached_contacts").then((cachedData) => { + setContacts([ + ...freshContacts, + ...cachedData.filter( + (item) => + !freshContacts.some((fresh) => fresh.user_id === item.user_id), + ), + ]); + }); + load("cached_communities").then((cachedData) => { + if (cachedData && freshCommunities) { + setCommunities([ + ...freshCommunities, + ...cachedData.filter( + (item) => + !freshCommunities.some( + (fresh) => fresh.community_id === item.community_id, + ), + ), + ]); + } + }); + }, [load, freshContacts, freshCommunities]); + + // Save data + useEffect(() => { + save("cached_contacts", contacts); + }, [contacts, save]); + useEffect(() => { + save("cached_communities", communities); + }, [communities, save]); + + const moveUserIdToTop = (userId: number) => { + setContacts((prevContacts) => { + const userIndex = prevContacts.findIndex( + (contact) => contact.user_id === userId, + ); + if (userIndex === -1) return prevContacts; + const [user] = prevContacts.splice(userIndex, 1); + return [user, ...prevContacts]; + }); + }; + + return ( + + {children} + + ); +} + +export function useSession(): SessionContextType { + const context = useContext(SessionContext); + if (context === undefined) { + throw new Error("useSession must be used within a SessionProvider"); + } + return context; +} diff --git a/packages/storage/todo.md b/packages/storage/todo.md new file mode 100644 index 0000000..244fc2c --- /dev/null +++ b/packages/storage/todo.md @@ -0,0 +1 @@ +- Add `notifications: number` to contacts diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index 22d0009..dc8bd06 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -25,15 +25,15 @@ import { TRANSPORT_URL, } from "./values"; import { + type Communities, + type Contacts, ttp as schemas, - ttp, type TTP as Schemas, } from "@tensamin/shared/data"; import { LoadingScreen as Loading } from "@tensamin/ui"; import { ErrorScreen } from "@tensamin/ui"; import { version } from "../../../package.json"; -import z from "zod"; import { decryptText } from "@tensamin/crypto/worker"; const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([ @@ -147,10 +147,8 @@ type ContextType = { ownPing: number; iotaPing: number; identified: boolean; - contacts: z.infer; - communities: z.infer< - typeof ttp.challenge_response.response.shape.communities - >; + freshContacts: Contacts; + freshCommunities: Communities; }; const TTPContext = createContext(undefined); @@ -178,12 +176,8 @@ export function Provider(props: { const [error, setError] = useState(""); const [errorDescription, setErrorDescription] = useState(""); - const [communities, setCommunities] = useState< - z.infer - >([]); - const [contacts, setContacts] = useState< - z.infer - >([]); + const [freshCommunities, setFreshCommunities] = useState([]); + const [freshContacts, setFreshContacts] = useState([]); const clientRef = useRef @@ -500,8 +494,8 @@ export function Provider(props: { }); // Data handling - setContacts(finalResponse.data.contacts); - setCommunities(finalResponse.data.communities); + setFreshContacts(finalResponse.data.contacts); + setFreshCommunities(finalResponse.data.communities); if (cancelled) { return; } @@ -613,8 +607,8 @@ export function Provider(props: { ownPing, iotaPing, identified, - contacts, - communities, + freshContacts, + freshCommunities, }} > {props.children} diff --git a/scripts/copy-licenses.ts b/scripts/copy-licenses.ts index eb8886b..5361e82 100644 --- a/scripts/copy-licenses.ts +++ b/scripts/copy-licenses.ts @@ -474,7 +474,9 @@ function dedupePackageRecords(records: PackageRecord[]): PackageRecord[] { unique.set(key, { ...existing, - copiedFiles: [...new Set([...existing.copiedFiles, ...record.copiedFiles])], + copiedFiles: [ + ...new Set([...existing.copiedFiles, ...record.copiedFiles]), + ], license: existing.license ?? record.license, description: existing.description ?? record.description, homepage: existing.homepage ?? record.homepage,