(feat): add cache for conversations and communities

(feat): conversations now get moved to the top when engaged with
(qol): update todo
This commit is contained in:
Alois 2026-05-01 16:40:16 +02:00
commit fd15215f15
14 changed files with 178 additions and 67 deletions

View file

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

View file

@ -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<HTMLDivElement | null>(null);

View file

@ -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 (
<SocketContext>
<UserContext>
<CallInit />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserContext>
</SocketContext>
<TTPProvider>
<Session>
<UserContext>
<CallInit />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserContext>
</Session>
</TTPProvider>
);
}

View file

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

View file

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

View file

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

View file

@ -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<contextType | undefined>(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<LiveMessage[]>([]);
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([]);

View file

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

View file

@ -39,6 +39,13 @@ export const failedUser = {
username: "unknown",
} as z.infer<typeof ttp.get_user_data.response>;
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<typeof legalDocsSchema>;
cached_contacts: Contacts;
cached_communities: Communities;
}
export const storageDefaults: Storage = {
@ -260,4 +269,6 @@ export const storageDefaults: Storage = {
unix: 0,
},
},
cached_contacts: [],
cached_communities: [],
};

View file

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

View file

@ -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<SessionContextType | undefined>(undefined);
export default function SessionProvider({ children }: { children: ReactNode }) {
const { freshContacts, freshCommunities } = useTTP();
const { load, save } = useStorage();
const [contacts, setContacts] = useState<Contacts>([]);
const [communities, setCommunities] = useState<Communities>([]);
// 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 (
<SessionContext.Provider value={{ contacts, communities, moveUserIdToTop }}>
{children}
</SessionContext.Provider>
);
}
export function useSession(): SessionContextType {
const context = useContext(SessionContext);
if (context === undefined) {
throw new Error("useSession must be used within a SessionProvider");
}
return context;
}

1
packages/storage/todo.md Normal file
View file

@ -0,0 +1 @@
- Add `notifications: number` to contacts

View file

@ -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<typeof ttp.challenge_response.response.shape.contacts>;
communities: z.infer<
typeof ttp.challenge_response.response.shape.communities
>;
freshContacts: Contacts;
freshCommunities: Communities;
};
const TTPContext = createContext<ContextType | undefined>(undefined);
@ -178,12 +176,8 @@ export function Provider(props: {
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const [communities, setCommunities] = useState<
z.infer<typeof ttp.challenge_response.response.shape.communities>
>([]);
const [contacts, setContacts] = useState<
z.infer<typeof ttp.challenge_response.response.shape.contacts>
>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const clientRef = useRef<ReturnType<
typeof createTransportClient<Schemas>
@ -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}

View file

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