(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

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