(feat): add cache package
All checks were successful
/ build-web (push) Successful in 7m26s
/ build-desktop (linux) (push) Successful in 11m29s
/ build-mobile (push) Successful in 19m15s
/ release (push) Successful in 3m3s

(feat): improve local storage security
(feat): move settings to dedicated settings package
This commit is contained in:
Alois 2026-07-11 22:08:01 +02:00
commit 790a1db788
54 changed files with 1984 additions and 947 deletions

25
packages/cache/package.json vendored Normal file
View file

@ -0,0 +1,25 @@
{
"name": "@tensamin/cache",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./helpers": "./src/helpers.ts",
"./schemas": "./src/schemas.ts",
"./sync": "./src/sync.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0",
"zod": "^4.3.6"
}
}

50
packages/cache/src/helpers.test.ts vendored Normal file
View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import type { CachedMessage, ConversationWindow } from "./schemas";
const message = (SendTime: number): CachedMessage => ({
SenderId: 1,
SendTime,
Content: "Y2lwaGVydGV4dA==",
MessageState: "received",
});
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
UserId,
LastMessageAt,
Messages: [],
});
describe("conversation cache helpers", () => {
it("selects the five most recent windows", () => {
const selected = selectConversationWindows(
[
window(1, 1),
window(2, 6),
window(3, 3),
window(4, 4),
window(5, 5),
window(6, 2),
],
5,
);
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
});
it("replaces only the matching conversation", () => {
expect(
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
).toEqual([window(1, 9), window(2, 2)]);
});
it("retains the newest messages in chronological order", () => {
expect(
trimMessages([message(2), message(3), message(1)], 2).map(
(item) => item.SendTime,
),
).toEqual([2, 3]);
});
});

30
packages/cache/src/helpers.ts vendored Normal file
View file

@ -0,0 +1,30 @@
import type { CachedMessage, ConversationWindow } from "./schemas";
export function trimMessages(
messages: readonly CachedMessage[],
limit: number,
): CachedMessage[] {
return [...messages]
.sort((a, b) => b.SendTime - a.SendTime)
.slice(0, limit)
.sort((a, b) => a.SendTime - b.SendTime);
}
export function replaceConversation(
windows: readonly ConversationWindow[],
replacement: ConversationWindow,
): ConversationWindow[] {
return [
replacement,
...windows.filter((item) => item.UserId !== replacement.UserId),
];
}
export function selectConversationWindows(
windows: readonly ConversationWindow[],
limit: number,
): ConversationWindow[] {
return [...windows]
.sort((a, b) => b.LastMessageAt - a.LastMessageAt || a.UserId - b.UserId)
.slice(0, limit);
}

276
packages/cache/src/index.ts vendored Normal file
View file

@ -0,0 +1,276 @@
import type { z } from "zod";
import { storageDefaults } from "@tensamin/shared/data";
import {
deleteDatabaseEntry,
getDatabaseEntry,
listDatabaseEntries,
setDatabaseEntry,
} from "@tensamin/shared/indexedDb";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import {
accountIdSchema,
contactsSchema,
conversationWindowSchema,
userProfileSchema,
type Contact,
type ConversationWindow,
type UserProfile,
} from "./schemas";
export * from "./helpers";
export * from "./schemas";
type CacheStore = "contacts" | "profiles" | "conversations";
export interface SecureValueCodec {
encode(value: unknown): unknown | Promise<unknown>;
decode(value: unknown): unknown | Promise<unknown>;
}
export interface CacheOptions {
codec?: SecureValueCodec;
contacts?: number;
messagesPerChat?: number;
}
export interface CacheLifecycle {
clearAccount(): Promise<void>;
close(): Promise<void>;
}
const identityCodec: SecureValueCodec = {
encode: (value) => value,
decode: (value) => value,
};
export function createCache(accountId: string, options: CacheOptions = {}) {
const account = accountIdSchema.parse(accountId);
const codec = options.codec ?? identityCodec;
const getLimits = async () => {
const [storedContacts, storedMessagesPerChat] = await Promise.all([
getDatabaseEntry("storage", "cache_contacts"),
getDatabaseEntry("storage", "cache_messages_per_chat"),
]);
return {
contacts: Math.max(
0,
Math.floor(
options.contacts ??
(typeof storedContacts === "number"
? storedContacts
: storageDefaults.cache_contacts),
),
),
messagesPerChat: Math.max(
0,
Math.floor(
options.messagesPerChat ??
(typeof storedMessagesPerChat === "number"
? storedMessagesPerChat
: storageDefaults.cache_messages_per_chat),
),
),
};
};
const prefix = `${account}:`;
const storedPrefix = (store: CacheStore) => `${store}:${prefix}`;
const storedKey = (store: CacheStore, key: string) =>
`${storedPrefix(store)}${key}`;
const entries = async (store: CacheStore) => {
const storePrefix = storedPrefix(store);
return (await listDatabaseEntries("cache", storePrefix)).map(
([key, value]) => [key.slice(storePrefix.length), value] as const,
);
};
let closed = false;
const ensureOpen = () => {
if (closed) throw new Error("Cache is closed");
};
const read = async <T>(
store: CacheStore,
key: string,
schema: z.ZodType<T>,
) => {
ensureOpen();
const value = await getDatabaseEntry("cache", storedKey(store, key));
return value === undefined
? undefined
: schema.parse(await codec.decode(value));
};
const write = async <T>(
store: CacheStore,
key: string,
schema: z.ZodType<T>,
value: T,
) => {
ensureOpen();
await setDatabaseEntry(
"cache",
storedKey(store, key),
await codec.encode(schema.parse(value)),
);
};
const remove = async (store: CacheStore, key: string) => {
ensureOpen();
await deleteDatabaseEntry("cache", storedKey(store, key));
};
const listConversations = async () => {
ensureOpen();
const storedEntries = await entries("conversations");
const windows = await Promise.all(
storedEntries.map(async ([, value]) =>
conversationWindowSchema.parse(await codec.decode(value)),
),
);
const { contacts } = await getLimits();
return selectConversationWindows(windows, contacts);
};
return {
contacts: {
get: () => read("contacts", "authoritative", contactsSchema),
replace: (contacts: Contact[]) =>
write("contacts", "authoritative", contactsSchema, contacts),
clear: () => remove("contacts", "authoritative"),
},
profiles: {
get: (userId: number) =>
read("profiles", String(userId), userProfileSchema),
put: (profile: UserProfile) =>
write("profiles", String(profile.UserId), userProfileSchema, profile),
delete: (userId: number) => remove("profiles", String(userId)),
},
conversations: {
list: listConversations,
get: (userId: number) =>
read("conversations", String(userId), conversationWindowSchema),
replace: async (window: ConversationWindow) => {
const { contacts, messagesPerChat } = await getLimits();
const candidate = conversationWindowSchema.parse({
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
});
const selected = selectConversationWindows(
replaceConversation(await listConversations(), candidate),
contacts,
);
await Promise.all(
selected.map((item) =>
write(
"conversations",
String(item.UserId),
conversationWindowSchema,
item,
),
),
);
const retained = new Set(selected.map((item) => item.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) => {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}),
);
},
replaceSelected: async (windows: ConversationWindow[]) => {
const { contacts, messagesPerChat } = await getLimits();
const selected = selectConversationWindows(
windows.map((window) => ({
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
})),
contacts,
);
await Promise.all(
selected.map((item) =>
write(
"conversations",
String(item.UserId),
conversationWindowSchema,
item,
),
),
);
const retained = new Set(selected.map((item) => item.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) => {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}),
);
},
prune: async () => {
const { messagesPerChat } = await getLimits();
const windows = await listConversations();
await Promise.all(
windows.map((window) =>
write(
"conversations",
String(window.UserId),
conversationWindowSchema,
{
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
},
),
),
);
const retained = new Set(windows.map((window) => window.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) =>
retained.has(Number(key))
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
],
),
);
},
delete: (userId: number) => remove("conversations", String(userId)),
},
clearAccount: async () => {
ensureOpen();
await Promise.all(
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
async (store) => {
const storedEntries = await entries(store);
await Promise.all(
storedEntries.map(([key]) =>
deleteDatabaseEntry("cache", storedKey(store, key)),
),
);
},
),
);
},
close: async () => {
closed = true;
},
};
}
export type Cache = ReturnType<typeof createCache>;

27
packages/cache/src/schemas.ts vendored Normal file
View file

@ -0,0 +1,27 @@
import { mtp } from "@tensamin/shared/data";
import { z } from "zod";
export const accountIdSchema = z.string().min(1);
export const contactSchema = z.object({
LastMessageAt: z.number(),
UserId: z.number(),
LastMessage: z
.object({ Content: z.base64(), SenderId: z.number() })
.optional(),
Messages: z.array(mtp.MessageGet.response),
});
export const contactsSchema = z.array(contactSchema);
export const userProfileSchema = mtp.GetUserData.response;
// Content remains the protocol base64 ciphertext. This package never decrypts messages.
export const cachedMessageSchema = mtp.MessageGet.response;
export const conversationWindowSchema = z.object({
UserId: z.number(),
LastMessageAt: z.number(),
Messages: z.array(cachedMessageSchema),
});
export type Contact = z.infer<typeof contactSchema>;
export type UserProfile = z.infer<typeof userProfileSchema>;
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;

271
packages/cache/src/sync.tsx vendored Normal file
View file

@ -0,0 +1,271 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
createCache,
type CachedMessage,
type UserProfile,
} from "@tensamin/cache";
import { useMTP, type MTPExchange, type ProtocolMessage } from "@tensamin/mtp";
import { useStorage } from "@tensamin/storage/context";
import { secureValueCodec } from "@tensamin/storage/secure";
function isError(message: ProtocolMessage) {
return message.type.startsWith("Error");
}
export default function CacheSync() {
const { addInterceptor, contextReady, freshContacts, subscribePush } =
useMTP();
const { load } = useStorage();
const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve());
const reactionPartnersRef = useRef(new Map<number, number>());
useEffect(() => {
void load("user_id").then(setAccountId);
}, [load]);
const enqueue = useCallback((operation: () => Promise<void>) => {
const next = queueRef.current.then(operation);
queueRef.current = next.catch(() => undefined);
return next;
}, []);
const secureCache = useCallback(
() =>
createCache(String(accountId), {
codec: secureValueCodec,
}),
[accountId],
);
const replaceMessage = useCallback(
async (
partnerId: number,
sendTime: number,
edit: Partial<CachedMessage>,
) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
if (!window) return;
await cache.conversations.replace({
...window,
Messages: window.Messages.map((message) =>
message.SendTime === sendTime ? { ...message, ...edit } : message,
),
});
},
[secureCache],
);
const insertMessage = useCallback(
async (partnerId: number, message: CachedMessage) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
await cache.conversations.replace({
UserId: partnerId,
LastMessageAt: Math.max(window?.LastMessageAt ?? 0, message.SendTime),
Messages: [
...(window?.Messages ?? []).filter(
(cached) => cached.SendTime !== message.SendTime,
),
message,
],
});
},
[secureCache],
);
const removeMessage = useCallback(
async (partnerId: number, sendTime: number) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
if (!window) return;
await cache.conversations.replace({
...window,
Messages: window.Messages.filter(
(message) => message.SendTime !== sendTime,
),
});
},
[secureCache],
);
useEffect(() => {
if (!accountId || !contextReady) return;
void enqueue(async () => {
const cache = secureCache();
await cache.contacts.replace(freshContacts);
await cache.conversations.replaceSelected(
freshContacts.map((contact) => ({
UserId: contact.UserId,
LastMessageAt: contact.LastMessageAt,
Messages: contact.Messages,
})),
);
});
}, [accountId, contextReady, enqueue, freshContacts, secureCache]);
const synchronizeExchange = useCallback(
async ({ type, data, response }: MTPExchange) => {
if (!accountId || isError(response)) return;
const request = (data ?? {}) as Record<string, unknown>;
const result = response.data as Record<string, unknown>;
if (type === "GetUserData") {
await createCache(String(accountId)).profiles.put(
result as unknown as UserProfile,
);
return;
}
if (type === "MessagesGet" && Number(request.Offset) === 0) {
const partnerId = Number(request.UserId);
const messages = result.Messages as CachedMessage[];
const cache = secureCache();
const previous = await cache.conversations.get(partnerId);
await cache.conversations.replace({
UserId: partnerId,
LastMessageAt: Math.max(
previous?.LastMessageAt ?? 0,
...messages.map((message) => message.SendTime),
),
// This replacement is authoritative: absent server messages are deleted.
Messages: messages,
});
return;
}
if (type === "MessageSend") {
const partnerId = Number(request.ReceiverId);
await insertMessage(partnerId, {
Content: String(request.Content),
Files: request.Files as CachedMessage["Files"],
MessageState: "sent",
SenderId: accountId,
SendTime: Number(request.SendTime),
});
return;
}
if (type === "MessageEdit") {
await replaceMessage(
Number(request.ChatPartnerId),
Number(request.SendTime),
{ Content: String(request.Content), Edited: true },
);
return;
}
if (type === "MessageDelete") {
await removeMessage(
Number(request.ChatPartnerId),
Number(request.SendTime),
);
return;
}
if (type === "MessageReactionAdd" || type === "MessageReactionRemove") {
const partnerId = Number(request.ChatPartnerId);
const sendTime = Number(request.SendTime);
const reaction = String(request.Reaction);
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
const message = window?.Messages.find(
(candidate) => candidate.SendTime === sendTime,
);
if (!message) return;
const reactions = (message.Reactions ?? []).filter(
(candidate) =>
candidate.SenderId !== accountId || candidate.Reaction !== reaction,
);
if (type === "MessageReactionAdd") {
reactions.push({ SenderId: accountId, Reaction: reaction });
}
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
return;
}
if (type === "MessageState") {
await replaceMessage(
Number(result.ChatPartnerId),
Number(result.SendTime),
{
MessageState: result.MessageState as CachedMessage["MessageState"],
},
);
return;
}
if (type === "MessageGet") {
const message = result as unknown as CachedMessage;
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
reactionPartnersRef.current.delete(message.SendTime);
if (mappedPartner) {
await insertMessage(mappedPartner, message);
return;
}
const windows = await secureCache().conversations.list();
const window = windows.find((candidate) =>
candidate.Messages.some(
(cached) => cached.SendTime === message.SendTime,
),
);
if (window) await insertMessage(window.UserId, message);
}
},
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
);
useEffect(() => {
if (!accountId) return;
return addInterceptor((exchange) =>
enqueue(() => synchronizeExchange(exchange)),
);
}, [accountId, addInterceptor, enqueue, synchronizeExchange]);
const synchronizePush = useCallback(
async (message: ProtocolMessage) => {
if (!accountId || isError(message)) return;
const data = message.data as Record<string, unknown>;
if (message.type === "MessageLive") {
await insertMessage(
Number(data.SenderId),
data.Message as CachedMessage,
);
return;
}
if (message.type === "MessageEditLive") {
await replaceMessage(
Number(data.ChatPartnerId),
Number(data.SendTime),
{ Content: String(data.Content), Edited: true },
);
return;
}
if (message.type === "MessageState") {
await replaceMessage(
Number(data.ChatPartnerId),
Number(data.SendTime),
{ MessageState: data.MessageState as CachedMessage["MessageState"] },
);
return;
}
if (message.type === "MessageReactionLive") {
reactionPartnersRef.current.set(
Number(data.SendTime),
Number(data.ChatPartnerId),
);
}
},
[accountId, insertMessage, replaceMessage],
);
useEffect(() => {
if (!accountId || !contextReady) return;
return subscribePush((message) => {
void enqueue(() => synchronizePush(message));
});
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
return null;
}

13
packages/cache/tsconfig.json vendored Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}

View file

@ -1,4 +1,5 @@
import { log } from "@tensamin/shared/log";
import type {} from "@tensamin/shared/desktopMedia";
import {
type LocalTrack,
Room,
@ -21,46 +22,6 @@ type ScreenShareStoreSetState = (
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
) => void;
declare global {
interface Window {
tensaminDesktop?: {
media?: {
getScreenShareCapabilities?: () => Promise<{
runtime?: "electron" | "tauri";
platform: "linux" | "macos" | "windows" | "other";
showAudioOutputSelector: boolean;
showAudioSwitch: boolean;
hasReliableSystemAudio: boolean;
}>;
listScreenShareSources?: () => Promise<
Array<{
id: string;
kind: "screen" | "window";
name: string;
subtitle?: string | null;
thumbnail?: string | null;
}>
>;
listScreenShareAudioOutputs?: () => Promise<
Array<{
id: string;
name: string;
isDefault: boolean;
}>
>;
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
};
call?: {
setStatus?: (status: {
inCall: boolean;
speaking: boolean;
iconDataUrl?: string;
}) => Promise<void>;
};
};
}
}
type ScreenShareControllerOptions = {
room: Room;
getState: () => ScreenShareStoreState;

View file

@ -16,6 +16,7 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/cache": "workspace:*",
"@tanstack/pacer": "^0.21.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-router": "^1.0.0",

View file

@ -27,6 +27,8 @@ import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context";
import { createCache } from "@tensamin/cache";
import { secureValueCodec } from "@tensamin/storage/secure";
export const context = createContext<contextType | undefined>(undefined);
@ -158,6 +160,7 @@ export default function Provider({ children }: { children: ReactNode }) {
const [errorDescription, setErrorDescription] = useState("");
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
const [ownId, setOwnId] = useState(0);
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
userId: number;
value: Uint8Array | null;
@ -186,6 +189,10 @@ export default function Provider({ children }: { children: ReactNode }) {
return currentChatSecretState.value;
}, [currentChatSecretState, userIdValue]);
useEffect(() => {
load("user_id").then(setOwnId);
}, [load]);
useEffect(() => {
if (!userIdValue) return;
@ -328,6 +335,44 @@ export default function Provider({ children }: { children: ReactNode }) {
[load, send],
);
const decryptMessages = useCallback(
async (messages: RawMessages) => {
if (!currentChatSecret) return [];
return Promise.all(
messages.map(async (message) => {
try {
return {
...message,
Content: await decryptChatText(
currentChatSecret,
message.Content,
),
};
} catch (err) {
log(1, "chat", "red", "Failed to decrypt historical message", err, {
SendTime: message.SendTime,
});
return {
...message,
Content: "Failed to decrypt message",
decryptionFailed: true,
};
}
}),
);
},
[currentChatSecret],
);
const getCachedMessages = useCallback(async () => {
if (!ownId || !userIdValue || !currentChatSecret) return [];
const cache = createCache(String(ownId), {
codec: secureValueCodec,
});
const window = await cache.conversations.get(userIdValue);
return decryptMessages(window?.Messages ?? []);
}, [currentChatSecret, decryptMessages, ownId, userIdValue]);
const getMessages = useCallback(
async (amount: number, offset: number) => {
if (!currentChatSecret) {
@ -359,36 +404,22 @@ export default function Provider({ children }: { children: ReactNode }) {
});
}
return await Promise.all(
sorted.map(async (message) => {
try {
return {
...message,
Content: await decryptChatText(
currentChatSecret,
message.Content,
),
};
} catch (err) {
log(1, "chat", "red", "Failed to decrypt historical message", err, {
SendTime: message.SendTime,
});
return {
...message,
Content: "Failed to decrypt message",
decryptionFailed: true,
};
}
}),
);
return decryptMessages(sorted);
},
[currentChatSecret, send, userIdValue],
[currentChatSecret, decryptMessages, send, userIdValue],
);
const [ownId, setOwnId] = useState(0);
useEffect(() => {
load("user_id").then(setOwnId);
}, [load]);
if (!currentChatSecret || !ownId || !userIdValue) return;
const queryKey = ["chat-messages", String(userIdValue), true] as const;
void getCachedMessages().then((messages) => {
if (messages.length === 0 || queryClient.getQueryData(queryKey)) return;
queryClient.setQueryData<InfiniteData<RawMessages>>(queryKey, {
pages: [messages],
pageParams: [0],
});
});
}, [currentChatSecret, getCachedMessages, ownId, userIdValue]);
const editMessage = useCallback(
(sendTime: number, edit: MessageEdit) => {
@ -452,7 +483,6 @@ export default function Provider({ children }: { children: ReactNode }) {
setLiveMessagesState((prev) =>
prev.filter((message) => message.SendTime !== sendTime),
);
const queryKey = [
"chat-messages",
String(userIdValue),

View file

@ -6,6 +6,7 @@
- Placeholder image if media fails to load
- Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box.
- Add arrow up hotkey to edit last message
- Add arrow up hotkey to edit last message (req: packages/hotkeys)
- Drop any unique reactions above 10
- Make the emoji picker not get moved with the mini context menu
- Add proper loading skeleton

View file

@ -60,6 +60,14 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
export type PushHandler = (message: ProtocolMessage) => void;
export type MTPExchange = {
type: keyof Schemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
type ContextType = {
send: BoundSendFn;
subscribe: <T extends keyof Schemas & string>(
@ -67,6 +75,7 @@ type ContextType = {
handler: (message: ProtocolMessage<T>) => void,
) => () => void;
subscribePush: (handler: PushHandler) => () => void;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
ownPing: number;
iotaPing: number;
@ -153,6 +162,7 @@ export function Provider(props: {
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const connected = readyState === ConnectionState.Connected;
@ -216,6 +226,11 @@ export function Provider(props: {
};
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
// Custom Pings
useEffect(() => {
if (!connected || !identified) {
@ -284,7 +299,13 @@ export function Provider(props: {
await MTPClient.init();
const userId = await load("user_id");
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
]);
if (!userId || !keyring) {
throw new Error("Missing login credentials");
}
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
@ -338,7 +359,7 @@ export function Provider(props: {
url,
credentials: {
clientId: userId,
keyring: base64ToUint8Array(await load("mtp_keyring")),
keyring: base64ToUint8Array(keyring),
},
hostPublicKey: omikronPublicKey,
descriptor: "client",
@ -396,6 +417,10 @@ export function Provider(props: {
(message) => {
try {
unsubscribe();
if (message.type.startsWith("Error")) {
reject(new Error(`Authentication failed: ${message.type}`));
return;
}
resolve(validateResponse("IdentificationResponse", message));
} catch (authPayloadError) {
unsubscribe();
@ -563,7 +588,15 @@ export function Provider(props: {
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
return mtp.send(type, data, options);
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: response as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return response;
},
[mtpRef],
);
@ -574,6 +607,7 @@ export function Provider(props: {
send: sendQueued,
subscribe,
subscribePush,
addInterceptor,
readyState,
ownPing,
iotaPing,

View file

@ -1,2 +1,8 @@
export { Provider, useMTP } from "./context";
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
PushHandler,
ProtocolMessage,
} from "./context";

View file

@ -0,0 +1,30 @@
{
"name": "@tensamin/settings",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tanstack/react-router": "^1.169.1",
"@tensamin/cache": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"lucide-react": "^1.14.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"vite": "^8.0.10"
}
}

View file

@ -0,0 +1,128 @@
import {
Button,
Checkbox,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Input,
Label,
Switch as UISwitch,
} from "@tensamin/ui";
import { useEffect, useState } from "react";
import { storageDefaults, type Storage } from "@tensamin/shared/data";
import { settingsStorageDefaults } from "@tensamin/shared/settings";
import { useStorage } from "@tensamin/storage/context";
type BooleanStorageKey = {
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
}[keyof Storage];
type ListStorageKey = {
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
}[keyof Storage];
type ListStorageItem<K extends ListStorageKey> =
Storage[K] extends Array<infer Item> ? Item : never;
export function Switch({ label, id }: {
label: React.ReactNode;
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
}) {
const { save, load } = useStorage();
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);
useEffect(() => {
load(id).then((value) => setValue(value));
}, [id, load]);
return (
<div className="flex gap-1">
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => {
setValue(nextValue);
save(id, nextValue);
}} />
<Label htmlFor={id}>{label}</Label>
</div>
);
}
export function List<K extends ListStorageKey>({ label, id }: {
label: React.ReactNode;
id: K;
}) {
const { save, load } = useStorage();
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
const [inputValue, setInputValue] = useState("");
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
useEffect(() => {
load(id).then((value) => setItems(value));
}, [id, load]);
const persistItems = (nextItems: Storage[K]) => {
setItems(nextItems);
save(id, nextItems);
};
const toStorageItem = (value: string): ListStorageItem<K> => {
const referenceItem = items[0] ?? storageDefaults[id][0];
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
};
const addItem = () => {
const trimmedValue = inputValue.trim();
if (!trimmedValue) return;
const nextItem = toStorageItem(trimmedValue);
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
persistItems([...items, nextItem] as Storage[K]);
setInputValue("");
};
const deleteItems = (indexes: Set<number>) => {
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K];
setItems(nextItems);
setSelectedItems(new Set());
save(id, nextItems);
};
return (
<div className="flex flex-col gap-2 pt-4">
<Label>{label}</Label>
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
<div className="flex gap-1">
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => {
if (event.key === "Enter") addItem();
}} />
<Button onClick={addItem}>Add item</Button>
</div>
<div className="flex flex-col gap-0">
{items.map((item, index) => {
const labelId = `${String(id)}-${index}`;
const selected = selectedItems.has(index);
const deletingSelectedItems = selectedItems.size > 1;
return (
<ContextMenu key={`${String(item)}-${index}`}>
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => {
const nextSelected = new Set(previous);
if (checked) nextSelected.add(index);
else nextSelected.delete(index);
return nextSelected;
})} />
<Label htmlFor={labelId}>{String(item)}</Label><div />
</div>} />
<ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}>
{deletingSelectedItems ? "Delete Selected" : "Delete"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import { createRoute, type AnyRoute } from "@tanstack/react-router";
import SettingsLayout from "./layout";
import { settingsPages } from "./manifest";
export function createSettingsRoute(parentRoute: AnyRoute) {
const settingsRoute = createRoute({
getParentRoute: () => parentRoute,
path: "settings",
component: SettingsLayout,
staticData: { showMobileNavbar: true },
});
return settingsRoute.addChildren(
settingsPages.map((page) =>
createRoute({
getParentRoute: () => settingsRoute,
path: page.path,
component: page.component,
staticData: { showMobileNavbar: true },
}),
),
);
}

View file

@ -0,0 +1,61 @@
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui";
import { settingsNavigation } from "./manifest";
export default function SettingsLayout() {
const isMobile = useIsMobile();
const location = useLocation();
return (
<div className="flex h-full w-full">
{!isMobile && <SettingsSidebar />}
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
<h1 className="text-xl font-semibold">
{location.pathname
.split("/")
.pop()
?.replace(/-/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase())}
</h1>
<Outlet />
</div>
</div>
);
}
export function SettingsSidebar() {
const isMobile = useIsMobile();
const navigate = useNavigate();
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
return (
<div
className={cn(
isMobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import Cache from "./pages/cache";
import Chat from "./pages/chat";
import Index from "./pages/index";
import Licenses from "./pages/licenses";
import Profile from "./pages/profile";
import Security from "./pages/security";
import Theme from "./pages/theme";
export const settingsPages = [
{ path: "/", component: Index },
{ category: "general", path: "chat", label: "Chat", component: Chat },
{
category: "account",
path: "profile",
label: "Profile",
component: Profile,
},
{
category: "account",
path: "security",
label: "Security",
component: Security,
},
{ category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme },
{
category: "application",
path: "licenses",
label: "Licenses",
component: Licenses,
},
] as const;
export const settingsNavigation = settingsPages.filter(
(page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> =>
page.path !== "/",
);

View file

@ -0,0 +1,101 @@
import { createCache } from "@tensamin/cache";
import { storageDefaults } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { secureValueCodec } from "@tensamin/storage/secure";
import { Button, Input, Label } from "@tensamin/ui";
import { useEffect, useState } from "react";
const validLimit = (value: number) => Number.isSafeInteger(value) && value >= 0;
export default function Page() {
const { load, save } = useStorage();
const [contacts, setContacts] = useState(storageDefaults.cache_contacts);
const [messagesPerChat, setMessagesPerChat] = useState(
storageDefaults.cache_messages_per_chat,
);
const [savedContacts, setSavedContacts] = useState(contacts);
const [savedMessagesPerChat, setSavedMessagesPerChat] =
useState(messagesPerChat);
const [saving, setSaving] = useState(false);
useEffect(() => {
void Promise.all([
load("cache_contacts"),
load("cache_messages_per_chat"),
]).then(([nextContacts, nextMessages]) => {
setContacts(nextContacts);
setSavedContacts(nextContacts);
setMessagesPerChat(nextMessages);
setSavedMessagesPerChat(nextMessages);
});
}, [load]);
const valid = validLimit(contacts) && validLimit(messagesPerChat);
const changed =
contacts !== savedContacts || messagesPerChat !== savedMessagesPerChat;
async function persist() {
if (!valid || saving) return;
setSaving(true);
try {
await Promise.all([
save("cache_contacts", contacts),
save("cache_messages_per_chat", messagesPerChat),
]);
const accountId = await load("user_id");
if (accountId)
await createCache(String(accountId), {
codec: secureValueCodec,
}).conversations.prune();
setSavedContacts(contacts);
setSavedMessagesPerChat(messagesPerChat);
} finally {
setSaving(false);
}
}
return (
<div className="flex max-w-xl flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="cache-contacts">Contacts</Label>
<Input
id="cache-contacts"
type="number"
min={0}
step={1}
value={Number.isNaN(contacts) ? "" : contacts}
onChange={(event) => setContacts(event.currentTarget.valueAsNumber)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cache-messages">Messages per chat</Label>
<Input
id="cache-messages"
type="number"
min={0}
step={1}
value={Number.isNaN(messagesPerChat) ? "" : messagesPerChat}
onChange={(event) =>
setMessagesPerChat(event.currentTarget.valueAsNumber)
}
/>
</div>
{!valid && (
<p className="text-sm text-destructive">
Cache limits must be whole numbers greater than or equal to 0.
</p>
)}
<div className="flex gap-2">
<Button disabled={!valid || !changed || saving} onClick={persist}>
Save
</Button>
<Button
variant="outline"
disabled={saving}
onClick={() => {
setContacts(storageDefaults.cache_contacts);
setMessagesPerChat(storageDefaults.cache_messages_per_chat);
}}
>
Reset
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { storageDefaults } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Button, Kbd } from "@tensamin/ui";
import { List, Switch } from "../components";
export default function Page() {
const { save } = useStorage();
return (
<div className="flex flex-col gap-2">
<Switch
label={
<p>
Change <Kbd>Enter</Kbd> behavior to <Kbd>Shift</Kbd> +{" "}
<Kbd>Enter</Kbd>
</p>
}
id="settings.reverse_enter_behavior"
/>
<Switch
label="Enable read confirmations"
id="settings.read_confirmations"
/>
<Switch
label="Enable receive confirmations"
id="settings.receive_confirmations"
/>
<Switch
label="Sidebar message preview"
id="settings.show_start_of_last_message_in_sidebar"
/>
<p className="text-destructive pt-6">
Trusted embed domains can get your IP-Address! Only add domains if you
really trust them!
</p>
<div>
<Button
variant="outline"
onClick={() => void save("reactions", storageDefaults.reactions)}
>
Reset Emoji Ranks
</Button>
</div>
<List label="Trusted embed domains" id="chat_trusted_domains" />
</div>
);
}

View file

@ -0,0 +1,7 @@
import { useIsMobile } from "@tensamin/ui";
import { SettingsSidebar } from "../layout";
export default function Page() {
const isMobile = useIsMobile();
return isMobile && <SettingsSidebar />;
}

View file

@ -0,0 +1,32 @@
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogContent, DialogTrigger } from "@tensamin/ui";
import { generatedAt, packageCount, packages } from "../../../../licenses/third-party-credits.json";
const licenseTexts = import.meta.glob("../../../../licenses/**/*", { eager: true, import: "default", query: "?raw" }) as Record<string, string>;
function getLicenseFiles(licensePackage: (typeof packages)[number]) {
return licensePackage.files.map((fileName) => ({
fileName,
text: licenseTexts["../../../../" + licensePackage.licenseFolder + "/" + fileName],
}));
}
export default function Page() {
return <div className="flex h-full min-h-0 flex-col gap-7">
<div className="flex flex-col"><p>Last generated: {generatedAt}</p><p>Package Count: {packageCount}</p></div>
<div className="min-h-0 flex-1 max-h-[calc(100vh-180px)] overflow-auto pr-2"><div className="flex flex-col gap-5">
{packages.map((licensePackage) => <Card key={licensePackage.name + licensePackage.version} id={licensePackage.name + licensePackage.version}>
<CardHeader><CardTitle className="flex gap-2 items-center"><Badge>{licensePackage.license}</Badge> {licensePackage.name} {licensePackage.version}</CardTitle></CardHeader>
{licensePackage.description && <CardContent><CardDescription>{licensePackage.description}</CardDescription></CardContent>}
<CardFooter className="gap-2"><LicenseDialog licensePackage={licensePackage} />
{licensePackage.repository ? <a target="_blank" rel="noreferrer" href={licensePackage.repository.replace("git+", "").replace(".git", "")}><Button variant="outline" className="cursor-pointer">Open Repository</Button></a> : <Button disabled variant="outline" className="cursor-pointer">Open Repository</Button>}
{licensePackage.homepage ? <a target="_blank" rel="noreferrer" href={licensePackage.homepage}><Button variant="outline" className="cursor-pointer">Open Homepage</Button></a> : <Button disabled variant="outline" className="cursor-pointer">Open Homepage</Button>}
</CardFooter>
</Card>)}
</div></div>
</div>;
}
function LicenseDialog({ licensePackage }: { licensePackage: (typeof packages)[number] }) {
const licenseFiles = getLicenseFiles(licensePackage);
return <Dialog><DialogTrigger render={<Button disabled={!licenseFiles.some(({ text }) => text)} className="cursor-pointer">Open License</Button>} /><DialogContent className="flex max-h-[85vh] min-h-0 flex-col overflow-hidden sm:max-w-3xl"><div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2">{licenseFiles.map(({ fileName, text }, index) => <section key={fileName} className="border-b last:border-b-0"><h3 className={`border-b pb-2 text-sm font-medium ${index >= 1 && "pt-2"}`}>{fileName}</h3><pre className="pt-2 whitespace-pre-wrap wrap-break-word text-xs leading-relaxed">{text || "License text could not be loaded. Please contact support@tensamin.net"}</pre></section>)}</div></DialogContent></Dialog>;
}

View file

@ -0,0 +1,67 @@
import MDInput from "@tensamin/markdown/input";
import { useMTP } from "@tensamin/mtp";
import { mtp } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Avatar, AvatarFallback, AvatarImage, Button, cn, Input, useIsMobile } from "@tensamin/ui";
import { useUser, type User } from "@tensamin/user/context";
import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react";
async function prepImage(file: File, size = 300, quality = 0.8): Promise<string> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not get canvas context");
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
context.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
return canvas.toDataURL("image/webp", quality);
}
export default function Page() {
const { get } = useUser();
const { load } = useStorage();
const { send } = useMTP();
const isMobile = useIsMobile();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [draftUser, setDraftUser] = useState<Partial<User>>({});
const [errorMessage, setErrorMessage] = useState("");
const [saveSucceeded, setSaveSucceeded] = useState(false);
const avatarUploadRef = useRef<HTMLInputElement>(null);
const draftInitializedRef = useRef(false);
const effectiveAvatar = draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
const updateDraftUser = (updater: (previous: Partial<User>) => Partial<User>) => {
setSaveSucceeded(false); setErrorMessage(""); setDraftUser(updater);
};
useEffect(() => { void (async () => setCurrentUser(await get(await load("user_id"))))(); }, [get, load]);
useEffect(() => {
if (!currentUser || draftInitializedRef.current) return;
setDraftUser(currentUser); draftInitializedRef.current = true;
}, [currentUser]);
async function handleAvatarUpload(file: File) {
const avatar = await prepImage(file);
updateDraftUser((previous) => ({ ...previous, avatar }));
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
}
if (!currentUser) return <p>Loading...</p>;
return <>
<input ref={avatarUploadRef} hidden onChange={(event) => event.target.files?.[0] && handleAvatarUpload(event.target.files[0])} type="file" />
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "w-80")}>
<div className="flex items-center gap-3"><Avatar className="size-14"><AvatarImage src={effectiveAvatar} /><AvatarFallback className="text-2xl">{draftUser.Display?.slice(0, 2).toUpperCase() || currentUser.Display.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar><div className="flex flex-col gap-1"><p>Avatar</p><div className="flex gap-1"><Button onClick={() => avatarUploadRef.current?.click()}>Upload avatar</Button><Button onClick={() => updateDraftUser((previous) => ({ ...previous, avatar: "none" }))} variant="destructive" disabled={effectiveAvatar === undefined}>Remove</Button></div><p className="text-sm text-muted-foreground">GIFs are supported in decentralised mode or with Tensamin Premium.<br />Maximum file size is 16mb.</p></div></div>
<Input className="w-full" onChange={(event) => updateDraftUser((previous) => ({ ...previous, display: event.target.value }))} placeholder="Display Name" value={draftUser.Display || ""} />
<Input className="w-full" onChange={(event) => updateDraftUser((previous) => ({ ...previous, username: event.target.value }))} placeholder="Username" value={draftUser.Username || ""} />
<MDInput styled paddingY="4px" paddingX="10px" fontSize=".875rem" placeholder="About Me" setValue={(value) => updateDraftUser((previous) => ({ ...previous, about: value }))} value={draftUser.About || ""} />
<Button onClick={async () => {
const { Avatar, ...draftUsersWithoutAvatar } = draftUser;
const payload = { ...draftUsersWithoutAvatar, ...(typeof Avatar === "string" ? { avatar: Avatar.startsWith("data:") ? (Avatar.split(",", 2)[1] ?? "") : Avatar } : {}) };
const validation = mtp.ChangeUserData.request.safeParse(payload);
if (!validation.success) { setSaveSucceeded(false); setErrorMessage(validation.error.issues[0]?.message ?? "Invalid profile data"); return; }
try { await send("ChangeUserData", validation.data); setSaveSucceeded(true); setErrorMessage(""); }
catch (error) { setSaveSucceeded(false); setErrorMessage("Failed to update profile: " + error); }
}}>{saveSucceeded ? <span className="inline-flex items-center gap-1.5"><Check className="size-4" />Saved</span> : "Save"}</Button>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
</div>
</>;
}

View file

@ -0,0 +1,25 @@
import { useStorage } from "@tensamin/storage/context";
import { Button, Input, Label } from "@tensamin/ui";
import { useEffect, useState } from "react";
export default function Page() {
const { save, load } = useStorage();
const [draftOmegaUrl, setDraftOmegaUrl] = useState("");
const [currentOmegaUrl, setCurrentOmegaUrl] = useState("");
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
const [currentForcedOmikronUrl, setCurrentForcedOmikronUrl] = useState("");
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] = useState("");
const [currentForcedOmikronPublicKey, setCurrentForcedOmikronPublicKey] = useState("");
useEffect(() => {
load("omega_url").then((value) => { setDraftOmegaUrl(value); setCurrentOmegaUrl(value); });
load("forced_omikron_url").then((value) => { setDraftForcedOmikronUrl(value || ""); setCurrentForcedOmikronUrl(value || ""); });
load("forced_omikron_public_key").then((value) => { setDraftForcedOmikronPublicKey(value || ""); setCurrentForcedOmikronPublicKey(value || ""); });
}, [load]);
return <div className="flex flex-col gap-8">
<p className="text-destructive">It's best not to touch these settings! They can be exploited to gain access to your account!</p>
<div className="flex flex-col gap-2"><Label>Omega Url</Label><div className="flex gap-1"><Input value={draftOmegaUrl} onChange={(event) => setDraftOmegaUrl(event.target.value)} /><Button disabled={currentOmegaUrl === draftOmegaUrl} onClick={() => save("omega_url", draftOmegaUrl).then(() => setCurrentOmegaUrl(draftOmegaUrl))}>Save</Button></div></div>
<div className="flex flex-col gap-2"><Label>Forced Omikron</Label><div className="flex gap-1"><Input placeholder="URL..." value={draftForcedOmikronUrl} onChange={(event) => setDraftForcedOmikronUrl(event.target.value)} /><Input placeholder="Public Key..." value={draftForcedOmikronPublicKey} onChange={(event) => setDraftForcedOmikronPublicKey(event.target.value)} /><Button disabled={currentForcedOmikronUrl === draftForcedOmikronUrl && currentForcedOmikronPublicKey === draftForcedOmikronPublicKey} onClick={() => { save("forced_omikron_url", draftForcedOmikronUrl).then(() => setCurrentForcedOmikronUrl(draftForcedOmikronUrl)); save("forced_omikron_public_key", draftForcedOmikronPublicKey).then(() => setCurrentForcedOmikronPublicKey(draftForcedOmikronPublicKey)); }}>Save</Button></div></div>
</div>;
}

View file

@ -0,0 +1,5 @@
import { StylePicker } from "@tensamin/ui";
export default function Page() {
return <div className="overflow-y-auto"><StylePicker /><div className="absolute bottom-0 right-0 mb-3 mr-2"><a className="block w-60 text-xs whitespace-pre-wrap" href="https://git.methanium.net/tensamin/client/issues/new" target="_blank" rel="noreferrer">Please open a Git issue to help us improve this feature. We want to get it right.</a></div></div>;
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View file

@ -9,6 +9,7 @@
"./data": "./src/data.ts",
"./desktopMedia": "./src/desktopMedia.tsx",
"./log": "./src/log.tsx",
"./indexedDb": "./src/indexedDb.ts",
"./settings": "./src/settings.ts",
"./features/legal/schema": "./src/features/legal/schema.ts",
"./features/conversation/schema": "./src/features/conversation/schema.ts"

View file

@ -423,6 +423,8 @@ export interface Storage extends SettingsStorageDefaults {
legal_docs: z.infer<typeof legalDocsSchema>;
cached_contacts: Contacts;
cached_communities: Communities;
cache_contacts: number;
cache_messages_per_chat: number;
omega_url: string;
forced_omikron_url: string | undefined;
forced_omikron_public_key: string | undefined;
@ -475,6 +477,8 @@ export const storageDefaults: Storage = {
},
cached_contacts: [],
cached_communities: [],
cache_contacts: 5,
cache_messages_per_chat: 20,
omega_url: "https://omega.tensamin.net",
forced_omikron_url: undefined,
forced_omikron_public_key: undefined,

View file

@ -38,6 +38,13 @@ type ElectronDesktopApi = {
iconDataUrl?: string;
}) => Promise<void>;
};
secureStorage?: {
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
load?: (key: string) => Promise<string | null>;
save?: (key: string, value: string) => Promise<void>;
delete?: (key: string) => Promise<void>;
clear?: () => Promise<void>;
};
};
declare global {

View file

@ -0,0 +1,98 @@
export const TENSAMIN_DB_NAME = "tensamin";
export const TENSAMIN_DB_VERSION = 1;
export type TensaminStore = "storage" | "cache" | "keys";
const stores: TensaminStore[] = ["storage", "cache", "keys"];
let databasePromise: Promise<IDBDatabase> | undefined;
export function openTensaminDatabase(
indexedDb: IDBFactory = globalThis.indexedDB,
) {
databasePromise ??= new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDb.open(TENSAMIN_DB_NAME, TENSAMIN_DB_VERSION);
request.onupgradeneeded = () => {
for (const store of stores) {
if (!request.result.objectStoreNames.contains(store)) {
request.result.createObjectStore(store);
}
}
};
request.onsuccess = () => {
request.result.onversionchange = () => request.result.close();
resolve(request.result);
};
request.onerror = () => {
databasePromise = undefined;
reject(request.error);
};
request.onblocked = () => {
databasePromise = undefined;
reject(new Error("The Tensamin database upgrade is blocked."));
};
});
return databasePromise;
}
export async function getDatabaseEntry<T>(store: TensaminStore, key: string) {
const database = await openTensaminDatabase();
return new Promise<T | undefined>((resolve, reject) => {
const request = database
.transaction(store, "readonly")
.objectStore(store)
.get(key);
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
}
export async function setDatabaseEntry(
store: TensaminStore,
key: string,
value: unknown,
) {
const database = await openTensaminDatabase();
return new Promise<void>((resolve, reject) => {
const transaction = database.transaction(store, "readwrite");
transaction.objectStore(store).put(value, key);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
export async function deleteDatabaseEntry(store: TensaminStore, key: string) {
const database = await openTensaminDatabase();
return new Promise<void>((resolve, reject) => {
const transaction = database.transaction(store, "readwrite");
transaction.objectStore(store).delete(key);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
export async function listDatabaseEntries(
store: TensaminStore,
keyPrefix: string,
) {
const database = await openTensaminDatabase();
return new Promise<Array<[string, unknown]>>((resolve, reject) => {
const entries: Array<[string, unknown]> = [];
const cursor = database
.transaction(store, "readonly")
.objectStore(store)
.openCursor();
cursor.onsuccess = () => {
const value = cursor.result;
if (!value) {
resolve(entries);
return;
}
if (typeof value.key === "string" && value.key.startsWith(keyPrefix)) {
entries.push([value.key, value.value]);
}
value.continue();
};
cursor.onerror = () => reject(cursor.error);
});
}

View file

@ -46,6 +46,7 @@ const settings = {
},
},
application: {
cache: {},
theme: {},
licenses: {},
},

View file

@ -6,7 +6,7 @@
"exports": {
"./session": "./src/session.tsx",
"./context": "./src/context.tsx",
"./indexed-db": "./src/indexed-db.ts"
"./secure": "./src/secure.ts"
},
"scripts": {
"format": "pnpm exec prettier --write .",
@ -14,6 +14,7 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/cache": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",

View file

@ -3,17 +3,32 @@ import {
type Storage as StorageSchema,
storageDefaults as defaults,
} from "@tensamin/shared/data";
import { getEntry, setEntry, deleteEntry } from "./indexed-db";
import {
deleteDatabaseEntry,
getDatabaseEntry,
setDatabaseEntry,
} from "@tensamin/shared/indexedDb";
import { ErrorScreen } from "@tensamin/ui";
import { log } from "@tensamin/shared/log";
import {
decodeSecureValue,
encodeSecureValue,
getSecureStorageStatus,
isSecureEnvelope,
type SecureStorageStatus,
} from "./secure";
export type SaveOptions = { secure?: boolean };
interface StorageContextValue {
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
save<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
options?: SaveOptions,
): Promise<void>;
clear: () => Promise<void>;
secureStorage: SecureStorageStatus | null;
}
const StorageContext = React.createContext<StorageContextValue | undefined>(
@ -30,96 +45,151 @@ const isIndexedDBSupported = typeof indexedDB !== "undefined";
export default function StorageProvider(props: { children: React.ReactNode }) {
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
const storageRef = React.useRef(storage);
const loadedKeys = React.useRef(new Set<keyof StorageSchema>());
const loadPromises = React.useRef(
new Map<keyof StorageSchema, Promise<StorageSchema[keyof StorageSchema]>>(),
);
const generations = React.useRef(new Map<keyof StorageSchema, number>());
const [secureStorage, setSecureStorage] =
React.useState<SecureStorageStatus | null>(null);
const secureStorageRef = React.useRef<SecureStorageStatus | null>(null);
const [error, setError] = React.useState("");
const [errorDescription, setErrorDescription] = React.useState("");
React.useEffect(() => {
storageRef.current = storage;
}, [storage]);
void getSecureStorageStatus().then((status) => {
secureStorageRef.current = status;
setSecureStorage(status);
});
}, []);
const loadIO = React.useCallback(
const commit = React.useCallback(
<K extends keyof StorageSchema>(key: K, nextValue: StorageSchema[K]) => {
const next = { ...storageRef.current, [key]: nextValue };
storageRef.current = next;
loadedKeys.current.add(key);
setStorage(next);
},
[],
);
const load = React.useCallback(
async <K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K]> => {
let stored: StorageSchema[K] | undefined;
if (loadedKeys.current.has(key)) return storageRef.current[key];
const pending = loadPromises.current.get(key);
if (pending) return pending as Promise<StorageSchema[K]>;
try {
stored = await getEntry(key);
} catch (err) {
setError("Failed to load data");
setErrorDescription(
"An error occurred while loading data from IndexedDB. Please try again.",
);
log(0, "Storage", "red", err);
}
if (stored !== undefined) {
setStorage((prev) => ({ ...prev, [key]: stored }));
return stored;
}
return defaults[key];
const generation = generations.current.get(key) ?? 0;
const request = (async () => {
try {
const desktopStorage = window.tensaminDesktop?.secureStorage;
const desktopStatus = desktopStorage?.getStatus
? await desktopStorage.getStatus()
: null;
const desktopValue =
desktopStatus?.available && desktopStorage?.load
? await desktopStorage.load(String(key))
: null;
let stored =
desktopValue === null
? await getDatabaseEntry<StorageSchema[K]>("storage", key)
: (JSON.parse(desktopValue) as StorageSchema[K]);
if (isSecureEnvelope(stored)) {
stored = (await decodeSecureValue(stored)) as StorageSchema[K];
}
const value = stored ?? defaults[key];
if ((generations.current.get(key) ?? 0) === generation) {
commit(key, value);
}
return (generations.current.get(key) ?? 0) === generation
? value
: storageRef.current[key];
} catch (err) {
setError("Failed to load data");
setErrorDescription(
"An error occurred while loading local data. Please reload and try again.",
);
log(0, "Storage", "red", err);
throw err;
} finally {
loadPromises.current.delete(key);
}
})();
loadPromises.current.set(
key,
request as Promise<StorageSchema[keyof StorageSchema]>,
);
return request;
},
[],
[commit],
);
const saveIO = React.useCallback(
const save = React.useCallback(
async <K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
options: SaveOptions = {},
): Promise<void> => {
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
const desktopStorage = window.tensaminDesktop?.secureStorage;
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
await deleteEntry(key);
setStorage((prev) => ({ ...prev, [key]: defaults[key] }));
if (desktopStorage?.delete)
await desktopStorage.delete(String(key)).catch(() => undefined);
await deleteDatabaseEntry("storage", key);
commit(key, defaults[key]);
} else {
await setEntry(key, value);
setStorage((prev) => ({ ...prev, [key]: value }));
const status = options.secure
? (secureStorageRef.current ?? (await getSecureStorageStatus()))
: secureStorageRef.current;
if (
options.secure &&
status?.backend === "electron-keyring" &&
desktopStorage?.save
) {
await desktopStorage.save(String(key), JSON.stringify(value));
await deleteDatabaseEntry("storage", key);
} else {
const persisted = options.secure
? await encodeSecureValue(value)
: value;
await setDatabaseEntry("storage", key, persisted as StorageSchema[K]);
}
commit(key, value);
}
},
[],
[commit],
);
const clear = React.useCallback(async () => {
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
for (const key of keys) {
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
}
await Promise.all(
keys.map((key) => deleteDatabaseEntry("storage", key)),
);
await window.tensaminDesktop?.secureStorage
?.clear?.()
.catch(() => undefined);
loadedKeys.current.clear();
loadPromises.current.clear();
storageRef.current = defaults;
setStorage(defaults);
}, []);
const value = React.useMemo<StorageContextValue>(
() => ({
async load<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K]> {
const current = storageRef.current[key];
if (
current === undefined ||
JSON.stringify(current) === JSON.stringify(defaults[key])
) {
const loadedValue = await loadIO(key);
setStorage((prev) => ({ ...prev, [key]: loadedValue }));
return loadedValue;
}
return current;
},
async save<K extends keyof StorageSchema>(
key: K,
nextValue: StorageSchema[K],
): Promise<void> {
await saveIO(key, nextValue);
},
async clear() {
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
await Promise.all(keys.map((key) => deleteEntry(key)));
setStorage(defaults);
},
load,
save,
clear,
secureStorage,
}),
[loadIO, saveIO],
[clear, load, save, secureStorage],
);
React.useEffect(() => {
// @ts-expect-error development utility
window.save = value.save;
}, [value]);
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}

View file

@ -1,92 +0,0 @@
import type { Storage as StorageSchema } from "@tensamin/shared/data";
const DB_NAME = "tensamin";
const DB_VERSION = 1;
const STORE_NAME = "storage";
let dbPromise: Promise<IDBDatabase> | null = null;
/**
* Executes openDB.
* @param none This function has no parameters.
* @returns Promise<IDBDatabase>.
*/
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return dbPromise;
}
/**
* Executes getEntry.
* @param key Parameter key.
* @returns Promise<StorageSchema[K] | undefined>.
*/
export async function getEntry<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K] | undefined> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const request = store.get(key as string);
request.onsuccess = () =>
resolve(request.result as StorageSchema[K] | undefined);
request.onerror = () => reject(request.error);
});
}
/**
* Executes setEntry.
* @param key Parameter key.
* @param value Parameter value.
* @returns Promise<void>.
*/
export async function setEntry<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.put(value, key as string);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Executes deleteEntry.
* @param key Parameter key.
* @returns Promise<void>.
*/
export async function deleteEntry<K extends keyof StorageSchema>(
key: K,
): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.delete(key as string);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

View file

@ -0,0 +1,142 @@
import type {} from "@tensamin/shared/desktopMedia";
import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
export type SecureStorageStatus = {
backend: "electron-keyring" | "webcrypto" | "indexeddb";
secure: boolean;
reason?: string;
};
type SecureEnvelope = {
__tensaminSecure: 1;
version: 1;
iv: string;
data: string;
};
const MASTER_KEY_NAME = "master-v1";
let keyPromise: Promise<CryptoKey | null> | undefined;
function bytesToBase64(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function loadBrowserKey() {
const existing = await getDatabaseEntry<CryptoKey>("keys", MASTER_KEY_NAME);
if (existing) return existing;
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
await setDatabaseEntry("keys", MASTER_KEY_NAME, key);
return key;
}
async function loadElectronKey() {
const storage = window.tensaminDesktop?.secureStorage;
if (!storage?.getStatus || !storage.load || !storage.save) return null;
const status = await storage.getStatus();
if (!status.available) return null;
let encoded = await storage.load(MASTER_KEY_NAME);
if (encoded === null) {
encoded = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
await storage.save(MASTER_KEY_NAME, encoded);
}
return crypto.subtle.importKey(
"raw",
base64ToBytes(encoded),
"AES-GCM",
false,
["encrypt", "decrypt"],
);
}
async function getKey() {
keyPromise ??= (async () => {
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
return null;
}
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
return loadBrowserKey();
})().catch(() => null);
return keyPromise;
}
export function isSecureEnvelope(value: unknown): value is SecureEnvelope {
if (!value || typeof value !== "object") return false;
const envelope = value as Partial<SecureEnvelope>;
return (
envelope.__tensaminSecure === 1 &&
envelope.version === 1 &&
typeof envelope.iv === "string" &&
typeof envelope.data === "string"
);
}
export async function encodeSecureValue(value: unknown): Promise<unknown> {
const key = await getKey();
if (!key) return value;
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext,
);
return {
__tensaminSecure: 1,
version: 1,
iv: bytesToBase64(iv),
data: bytesToBase64(new Uint8Array(encrypted)),
} satisfies SecureEnvelope;
}
export async function decodeSecureValue(value: unknown): Promise<unknown> {
if (!isSecureEnvelope(value)) return value;
const key = await getKey();
if (!key) throw new Error("Secure storage key is unavailable.");
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBytes(value.iv) },
key,
base64ToBytes(value.data),
);
return JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
}
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
const desktop = window.tensaminDesktop?.secureStorage;
if (desktop?.getStatus) {
const status = await desktop.getStatus();
if (status.available) return { backend: "electron-keyring", secure: true };
return {
backend: "indexeddb",
secure: false,
reason:
status.backend === "basic_text"
? "The operating system keyring is unavailable."
: "Electron secure storage is unavailable.",
};
}
return (await getKey())
? { backend: "webcrypto", secure: true }
: {
backend: "indexeddb",
secure: false,
reason: "This browser cannot protect local credentials with WebCrypto.",
};
}
export const secureValueCodec = {
encode: encodeSecureValue,
decode: decodeSecureValue,
};

View file

@ -8,6 +8,8 @@ import {
} from "react";
import { useStorage } from "./context";
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
import { createCache } from "@tensamin/cache";
import { secureValueCodec } from "./secure";
interface SessionContextType {
contacts: Contacts;
@ -21,11 +23,13 @@ interface SessionContextType {
const SessionContext = createContext<SessionContextType | undefined>(undefined);
export default function SessionProvider({ children }: { children: ReactNode }) {
const { freshContacts, freshCommunities, freshCalls } = useMTP();
const { freshContacts, freshCommunities, freshCalls, contextReady } =
useMTP();
const { load, save } = useStorage();
const [contacts, setContacts] = useState<Contacts>([]);
const [communities, setCommunities] = useState<Communities>([]);
const [localCalls, setLocalCalls] = useState<Calls>([]);
const [accountId, setAccountId] = useState<number | null>(null);
const calls = [
...freshCalls,
...localCalls.filter(
@ -33,17 +37,27 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
),
];
// Cached session data fills in items the server did not return freshly.
useEffect(() => {
load("cached_contacts").then((cachedData) => {
setContacts([
...freshContacts,
...cachedData.filter(
(item) =>
!freshContacts.some((fresh) => fresh.UserId === item.UserId),
),
]);
void load("user_id").then(setAccountId);
}, [load]);
// Cached contacts seed the session, then authenticated server data replaces them.
useEffect(() => {
if (!accountId) return;
const cache = createCache(String(accountId), {
codec: secureValueCodec,
});
void cache.contacts.get().then((cached) => {
if (cached) setContacts(cached);
});
}, [accountId]);
useEffect(() => {
if (!accountId || !contextReady) return;
setContacts(freshContacts);
}, [accountId, contextReady, freshContacts]);
useEffect(() => {
load("cached_communities").then((cachedData) => {
if (cachedData && freshCommunities) {
setCommunities([
@ -57,11 +71,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
]);
}
});
}, [load, freshContacts, freshCommunities]);
useEffect(() => {
save("cached_contacts", contacts);
}, [contacts, save]);
}, [load, freshCommunities]);
useEffect(() => {
save("cached_communities", communities);
}, [communities, save]);
@ -72,8 +82,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
(contact) => contact.UserId === userId,
);
if (userIndex === -1) return prevContacts;
const [user] = prevContacts.splice(userIndex, 1);
return [user, ...prevContacts];
const user = prevContacts[userIndex];
return [
user,
...prevContacts.slice(0, userIndex),
...prevContacts.slice(userIndex + 1),
];
});
};

View file

@ -14,6 +14,7 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/cache": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -3,6 +3,9 @@ import { useMTP } from "@tensamin/mtp";
import { mtp as schemas } from "@tensamin/shared/data";
import type z from "zod";
import { createCache } from "@tensamin/cache";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
export type User = z.infer<typeof schemas.GetUserData.response>;
@ -24,6 +27,15 @@ export default function UserProvider(props: { children: React.ReactNode }) {
);
const { send } = useMTP();
const { load } = useStorage();
const { contacts } = useSession();
const [accountId, setAccountId] = React.useState<number | null>(null);
React.useEffect(() => {
void load("user_id").then((accountId) => {
setAccountId(accountId);
});
}, [load]);
/**
* Executes get.
@ -36,27 +48,30 @@ export default function UserProvider(props: { children: React.ReactNode }) {
throw new Error("userId is required");
}
const cachedUser = storageRef.current[userId];
if (cachedUser !== undefined) {
return cachedUser;
}
const pendingUser = pendingRef.current[userId];
if (pendingUser !== undefined) {
return pendingUser;
}
const request = (async () => {
const userData = await send("GetUserData", { UserId: userId });
const user = {
...userData.data,
avatar: userData.data.Avatar
? `data:image/webp;base64,${atob(userData.data.Avatar)}`
: undefined,
};
storageRef.current[userId] = user;
return user;
const cache = accountId ? createCache(String(accountId)) : null;
const cached =
storageRef.current[userId] ?? (await cache?.profiles.get(userId));
if (cached) storageRef.current[userId] = cached;
try {
const userData = await send("GetUserData", { UserId: userId });
const user = {
...userData.data,
avatar: userData.data.Avatar
? `data:image/webp;base64,${atob(userData.data.Avatar)}`
: undefined,
};
storageRef.current[userId] = user;
return user;
} catch (error) {
if (cached) return cached;
throw error;
}
})();
pendingRef.current[userId] = request;
@ -67,9 +82,14 @@ export default function UserProvider(props: { children: React.ReactNode }) {
delete pendingRef.current[userId];
}
},
[send],
[accountId, send],
);
React.useEffect(() => {
if (!accountId) return;
for (const contact of contacts) void get(contact.UserId);
}, [accountId, contacts, get]);
return (
<UserContext.Provider value={{ get }}>
{props.children}