(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
|
|
@ -17,6 +17,13 @@ import {
|
||||||
type DesktopScreenShareCapabilities,
|
type DesktopScreenShareCapabilities,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
import { initTray, setTrayCallStatus } from "./tray.js";
|
import { initTray, setTrayCallStatus } from "./tray.js";
|
||||||
|
import {
|
||||||
|
clearSecureStorage,
|
||||||
|
deleteSecureStorage,
|
||||||
|
getSecureStorageStatus,
|
||||||
|
loadSecureStorage,
|
||||||
|
saveSecureStorage,
|
||||||
|
} from "./secureStorage.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const verbose = process.argv.includes("--verbose");
|
const verbose = process.argv.includes("--verbose");
|
||||||
|
|
@ -190,6 +197,18 @@ function registerIpc() {
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||||
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
||||||
|
ipcMain.handle(ipcChannels.getSecureStorageStatus, getSecureStorageStatus);
|
||||||
|
ipcMain.handle(ipcChannels.loadSecureStorage, (_event, key: unknown) =>
|
||||||
|
loadSecureStorage(key),
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.saveSecureStorage,
|
||||||
|
(_event, key: unknown, value: unknown) => saveSecureStorage(key, value),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.deleteSecureStorage, (_event, key: unknown) =>
|
||||||
|
deleteSecureStorage(key),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
||||||
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
||||||
if (
|
if (
|
||||||
typeof status !== "object" ||
|
typeof status !== "object" ||
|
||||||
|
|
|
||||||
130
apps/electron/src/main/secureStorage.ts
Normal file
130
apps/electron/src/main/secureStorage.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { app, safeStorage } from "electron";
|
||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
secureStorageLimits,
|
||||||
|
type DesktopSecureStorageStatus,
|
||||||
|
} from "../shared/ipc.js";
|
||||||
|
|
||||||
|
type StoredValues = Record<string, string>;
|
||||||
|
|
||||||
|
let pendingWrite = Promise.resolve();
|
||||||
|
|
||||||
|
function storagePath() {
|
||||||
|
return join(app.getPath("userData"), "secure-storage.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKey(key: unknown): asserts key is string {
|
||||||
|
if (
|
||||||
|
typeof key !== "string" ||
|
||||||
|
key.length === 0 ||
|
||||||
|
Buffer.byteLength(key, "utf8") > secureStorageLimits.maxKeyBytes
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid secure storage key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateValue(value: unknown): asserts value is string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
Buffer.byteLength(value, "utf8") > secureStorageLimits.maxValueBytes
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid secure storage value.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
return { available: false, backend: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend =
|
||||||
|
process.platform === "linux"
|
||||||
|
? safeStorage.getSelectedStorageBackend()
|
||||||
|
: process.platform === "darwin"
|
||||||
|
? "keychain"
|
||||||
|
: process.platform === "win32"
|
||||||
|
? "dpapi"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: process.platform !== "linux" || backend !== "basic_text",
|
||||||
|
backend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAvailable() {
|
||||||
|
if (!getSecureStorageStatus().available) {
|
||||||
|
throw new Error("Secure storage is unavailable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readValues(): Promise<StoredValues> {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(await readFile(storagePath(), "utf8"));
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("Invalid secure storage data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = parsed as Record<string, unknown>;
|
||||||
|
if (Object.values(values).some((value) => typeof value !== "string")) {
|
||||||
|
throw new Error("Invalid secure storage data.");
|
||||||
|
}
|
||||||
|
return values as StoredValues;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeValues(values: StoredValues) {
|
||||||
|
const path = storagePath();
|
||||||
|
const temporaryPath = `${path}.tmp`;
|
||||||
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(temporaryPath, JSON.stringify(values), { mode: 0o600 });
|
||||||
|
await rename(temporaryPath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateValues(mutation: (values: StoredValues) => void) {
|
||||||
|
const operation = pendingWrite.then(async () => {
|
||||||
|
const values = await readValues();
|
||||||
|
mutation(values);
|
||||||
|
await writeValues(values);
|
||||||
|
});
|
||||||
|
pendingWrite = operation.catch(() => undefined);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSecureStorage(key: unknown): Promise<string | null> {
|
||||||
|
requireAvailable();
|
||||||
|
validateKey(key);
|
||||||
|
await pendingWrite;
|
||||||
|
const encrypted = (await readValues())[key];
|
||||||
|
if (encrypted === undefined) return null;
|
||||||
|
return safeStorage.decryptString(Buffer.from(encrypted, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSecureStorage(key: unknown, value: unknown) {
|
||||||
|
requireAvailable();
|
||||||
|
validateKey(key);
|
||||||
|
validateValue(value);
|
||||||
|
const encrypted = safeStorage.encryptString(value).toString("base64");
|
||||||
|
return mutateValues((values) => {
|
||||||
|
values[key] = encrypted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSecureStorage(key: unknown) {
|
||||||
|
requireAvailable();
|
||||||
|
validateKey(key);
|
||||||
|
return mutateValues((values) => {
|
||||||
|
delete values[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSecureStorage() {
|
||||||
|
requireAvailable();
|
||||||
|
return mutateValues((values) => {
|
||||||
|
for (const key of Object.keys(values)) delete values[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,21 @@ import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type DesktopCallStatus,
|
type DesktopCallStatus,
|
||||||
type DesktopScreenShareSource,
|
type DesktopScreenShareSource,
|
||||||
|
secureStorageLimits,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
|
|
||||||
function windowAction(channel: string) {
|
function windowAction(channel: string) {
|
||||||
return () => ipcRenderer.invoke(channel);
|
return () => ipcRenderer.invoke(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validKey(key: string) {
|
||||||
|
return (
|
||||||
|
typeof key === "string" &&
|
||||||
|
key.length > 0 &&
|
||||||
|
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const desktopApi = {
|
const desktopApi = {
|
||||||
media: {
|
media: {
|
||||||
listScreenShareSources: () =>
|
listScreenShareSources: () =>
|
||||||
|
|
@ -46,6 +55,24 @@ const desktopApi = {
|
||||||
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
secureStorage: {
|
||||||
|
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
||||||
|
load: (key: string) =>
|
||||||
|
validKey(key)
|
||||||
|
? ipcRenderer.invoke(ipcChannels.loadSecureStorage, key)
|
||||||
|
: Promise.reject(new Error("Invalid secure storage key.")),
|
||||||
|
save: (key: string, value: string) =>
|
||||||
|
validKey(key) &&
|
||||||
|
typeof value === "string" &&
|
||||||
|
Buffer.byteLength(value, "utf8") <= secureStorageLimits.maxValueBytes
|
||||||
|
? ipcRenderer.invoke(ipcChannels.saveSecureStorage, key, value)
|
||||||
|
: Promise.reject(new Error("Invalid secure storage key or value.")),
|
||||||
|
delete: (key: string) =>
|
||||||
|
validKey(key)
|
||||||
|
? ipcRenderer.invoke(ipcChannels.deleteSecureStorage, key)
|
||||||
|
: Promise.reject(new Error("Invalid secure storage key.")),
|
||||||
|
clear: () => ipcRenderer.invoke(ipcChannels.clearSecureStorage),
|
||||||
|
},
|
||||||
window: {
|
window: {
|
||||||
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
||||||
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ export type DesktopCallStatus = {
|
||||||
iconDataUrl?: string;
|
iconDataUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DesktopSecureStorageStatus = {
|
||||||
|
available: boolean;
|
||||||
|
backend: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secureStorageLimits = {
|
||||||
|
maxKeyBytes: 256,
|
||||||
|
maxValueBytes: 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type ReleaseArtifact = {
|
export type ReleaseArtifact = {
|
||||||
name: string;
|
name: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
|
@ -62,4 +72,9 @@ export const ipcChannels = {
|
||||||
getVersion: "app:getVersion",
|
getVersion: "app:getVersion",
|
||||||
checkForUpdates: "updates:checkForUpdates",
|
checkForUpdates: "updates:checkForUpdates",
|
||||||
setCallStatus: "call:setStatus",
|
setCallStatus: "call:setStatus",
|
||||||
|
getSecureStorageStatus: "secureStorage:getStatus",
|
||||||
|
loadSecureStorage: "secureStorage:load",
|
||||||
|
saveSecureStorage: "secureStorage:save",
|
||||||
|
deleteSecureStorage: "secureStorage:delete",
|
||||||
|
clearSecureStorage: "secureStorage:clear",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,11 @@
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tensamin/call": "workspace:*",
|
"@tensamin/call": "workspace:*",
|
||||||
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
|
"@tensamin/settings": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"@tensamin/tauri": "workspace:*",
|
"@tensamin/tauri": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
<House className="size-4.5" />
|
<House className="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
|
||||||
onClick={() => navigate({ to: "/settings" })}
|
onClick={() => navigate({ to: "/settings" })}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9 aspect-square rounded-lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -195,7 +194,6 @@ export function MobileNavbar() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
|
||||||
navigate({ to: "/settings" });
|
navigate({ to: "/settings" });
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,30 @@ export default function Form() {
|
||||||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const { save } = useStorage();
|
const { save } = useStorage();
|
||||||
|
const loginPendingRef = React.useRef(false);
|
||||||
|
|
||||||
|
const persistLogin = React.useCallback(
|
||||||
|
async (userId: number, privateKey: string, domain?: string | null) => {
|
||||||
|
if (loginPendingRef.current) return false;
|
||||||
|
loginPendingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (domain) await save("omega_url", `https://${domain}/`);
|
||||||
|
await save("mtp_keyring", privateKey, { secure: true });
|
||||||
|
await save("session_id", Date.now());
|
||||||
|
await save("user_id", userId);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
window.location.protocol === "file:" ? "#/" : "/",
|
||||||
|
);
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
loginPendingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[save],
|
||||||
|
);
|
||||||
|
|
||||||
// Process dropped files
|
// Process dropped files
|
||||||
const processDroppedFile = React.useCallback(
|
const processDroppedFile = React.useCallback(
|
||||||
|
|
@ -93,20 +117,13 @@ export default function Form() {
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const parsed = parseTuFileContent(raw);
|
const parsed = parseTuFileContent(raw);
|
||||||
|
|
||||||
await save("session_id", Date.now());
|
await persistLogin(parsed.userId, parsed.privateKey, parsed.domain);
|
||||||
await save("user_id", parsed.userId);
|
|
||||||
await save("mtp_keyring", parsed.privateKey);
|
|
||||||
if (parsed.domain) {
|
|
||||||
await save("omega_url", `https://${parsed.domain}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
location.href = "/";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(0, "login", "red", error);
|
log(0, "login", "red", error);
|
||||||
toast("error", "Failed to load file");
|
toast("error", "Failed to load file");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[save],
|
[persistLogin],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle .tu files
|
// Handle .tu files
|
||||||
|
|
@ -236,20 +253,13 @@ export default function Form() {
|
||||||
|
|
||||||
const user = parse.data;
|
const user = parse.data;
|
||||||
|
|
||||||
await save("session_id", Date.now());
|
await persistLogin(user.user_id, inputParse.data.mtp_keyring, domain);
|
||||||
await save("user_id", user.user_id);
|
|
||||||
await save("mtp_keyring", inputParse.data.mtp_keyring);
|
|
||||||
if (domain) {
|
|
||||||
await save("omega_url", `https://${domain}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
location.href = "/";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(0, "login", "red", error);
|
log(0, "login", "red", error);
|
||||||
toast("error", "Failed to fetch user data");
|
toast("error", "Failed to fetch user data");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[save],
|
[persistLogin],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -269,15 +279,7 @@ export default function Form() {
|
||||||
const { userId, privateKey, domain } =
|
const { userId, privateKey, domain } =
|
||||||
parseTuFileContent(decoded);
|
parseTuFileContent(decoded);
|
||||||
|
|
||||||
await save("session_id", Date.now());
|
await persistLogin(userId, privateKey, domain);
|
||||||
await save("user_id", userId);
|
|
||||||
await save("mtp_keyring", privateKey);
|
|
||||||
|
|
||||||
if (domain) {
|
|
||||||
await save("omega_url", `https://${domain}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
location.href = "/";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(0, "login", "red", error);
|
log(0, "login", "red", error);
|
||||||
toast("error", "Failed to parse QR code data");
|
toast("error", "Failed to parse QR code data");
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import "@tensamin/ui/index.css";
|
||||||
import NotFound from "@/routes/404";
|
import NotFound from "@/routes/404";
|
||||||
|
|
||||||
import AppLayout from "@/routes/app/layout";
|
import AppLayout from "@/routes/app/layout";
|
||||||
import SettingsLayout from "@/features/settings/layout";
|
import { createSettingsRoute } from "@tensamin/settings";
|
||||||
|
|
||||||
import Home from "@/routes/app/home";
|
import Home from "@/routes/app/home";
|
||||||
import ChatScreen from "@tensamin/chat/screen";
|
import ChatScreen from "@tensamin/chat/screen";
|
||||||
|
|
@ -41,8 +41,10 @@ import Storage from "@tensamin/storage/context";
|
||||||
import Session from "@tensamin/storage/session";
|
import Session from "@tensamin/storage/session";
|
||||||
import Crypto from "@tensamin/crypto/context";
|
import Crypto from "@tensamin/crypto/context";
|
||||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
import LegalWrapper from "@/features/legal/screen";
|
import LegalWrapper from "@/features/legal/screen";
|
||||||
|
import CacheSync from "@tensamin/cache/sync";
|
||||||
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -71,34 +73,39 @@ window.setLogLevelToMax = () => {
|
||||||
function LoginWrapper({ children }: { children: ReactNode }) {
|
function LoginWrapper({ children }: { children: ReactNode }) {
|
||||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const { load } = useStorage();
|
const { load, secureStorage } = useStorage();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (secureStorage === null) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
load("user_id").then((userId) => {
|
Promise.all([load("user_id"), load("mtp_keyring")])
|
||||||
if (!active) {
|
.then(([userId, keyring]) => {
|
||||||
return;
|
if (!active) return;
|
||||||
}
|
if (userId !== 0 && keyring !== "") {
|
||||||
|
|
||||||
if (userId !== 0) {
|
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
|
if (location.pathname === "/login") {
|
||||||
|
void navigate({ to: "/", replace: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoggedIn(false);
|
setLoggedIn(false);
|
||||||
navigate({
|
void navigate({
|
||||||
to: "/login",
|
to: "/login",
|
||||||
|
replace: true,
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log(0, "login", "red", "Failed to load login state", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [load, navigate]);
|
}, [load, location.pathname, navigate, secureStorage]);
|
||||||
|
|
||||||
if (loggedIn !== true && location.pathname !== "/login") {
|
if (loggedIn !== true && location.pathname !== "/login") {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -256,6 +263,7 @@ function RootShell() {
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<MTPProvider>
|
<MTPProvider>
|
||||||
|
<CacheSync />
|
||||||
<Session>
|
<Session>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<CallInit />
|
<CallInit />
|
||||||
|
|
@ -382,47 +390,7 @@ const appRoute = createRoute({
|
||||||
notFoundComponent: NotFound,
|
notFoundComponent: NotFound,
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsRoute = createRoute({
|
const settingsRoute = createSettingsRoute(appRoute);
|
||||||
getParentRoute: () => appRoute,
|
|
||||||
path: "settings",
|
|
||||||
component: SettingsLayout,
|
|
||||||
staticData: {
|
|
||||||
showMobileNavbar: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type SettingsRouteModule = {
|
|
||||||
default?: () => React.JSX.Element;
|
|
||||||
component?: () => React.JSX.Element;
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsRouteModules = import.meta.glob<SettingsRouteModule>(
|
|
||||||
"./routes/settings/*.tsx",
|
|
||||||
{ eager: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const settingsChildren = Object.entries(settingsRouteModules).map(
|
|
||||||
([filePath, module]) => {
|
|
||||||
const fileName = filePath.split("/").pop()?.replace(".tsx", "") ?? "";
|
|
||||||
const path = fileName === "index" ? "/" : fileName;
|
|
||||||
const component = module.component ?? module.default;
|
|
||||||
|
|
||||||
if (!component) {
|
|
||||||
throw new Error(
|
|
||||||
`Settings route module "${filePath}" must export a default component`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path,
|
|
||||||
component,
|
|
||||||
staticData: {
|
|
||||||
showMobileNavbar: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const homeRoute = createRoute({
|
const homeRoute = createRoute({
|
||||||
getParentRoute: () => appRoute,
|
getParentRoute: () => appRoute,
|
||||||
|
|
@ -464,12 +432,7 @@ const loginRoute = createRoute({
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
appRoute.addChildren([
|
appRoute.addChildren([homeRoute, chatRoute, callRoute, settingsRoute]),
|
||||||
homeRoute,
|
|
||||||
chatRoute,
|
|
||||||
callRoute,
|
|
||||||
settingsRoute.addChildren(settingsChildren),
|
|
||||||
]),
|
|
||||||
loginRoute,
|
loginRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,36 @@ import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
// The page
|
// The page
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { secureStorage } = useStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`px-3 flex gap-2 ${!(isTauri() && isMobile) && "pt-3"}`}>
|
<div
|
||||||
|
className={`px-3 flex flex-col gap-3 ${!(isTauri() && isMobile) && "pt-3"}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
<AddConversationButton />
|
<AddConversationButton />
|
||||||
<Button disabled>Add Community</Button>
|
<Button disabled>Add Community</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{secureStorage && !secureStorage.secure && (
|
||||||
|
<div className="flex max-w-2xl gap-3 rounded-lg border border-(--destructive)/60 bg-(--destructive)/10 p-3 text-sm">
|
||||||
|
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Secure storage is unavailable</p>
|
||||||
|
<p>{secureStorage.reason}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your keyring and cached messages will get saved in regular
|
||||||
|
storage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardTitle,
|
|
||||||
CardHeader,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import {
|
|
||||||
packages,
|
|
||||||
packageCount,
|
|
||||||
generatedAt,
|
|
||||||
} 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) => {
|
|
||||||
const path =
|
|
||||||
"../../../../../" + licensePackage.licenseFolder + "/" + fileName;
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileName,
|
|
||||||
text: licenseTexts[path],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
const hasLicenseText = licenseFiles.some(({ text }) => text);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger
|
|
||||||
render={
|
|
||||||
<Button disabled={!hasLicenseText} 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
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 { useEffect, useRef, useState } from "react";
|
|
||||||
import MDInput from "@tensamin/markdown/input";
|
|
||||||
import { mtp } from "@tensamin/shared/data";
|
|
||||||
import { useMTP } from "@tensamin/mtp";
|
|
||||||
import { Check } from "lucide-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 ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) 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;
|
|
||||||
const x = (size - width) / 2;
|
|
||||||
const y = (size - height) / 2;
|
|
||||||
|
|
||||||
ctx.drawImage(bitmap, x, y, width, height);
|
|
||||||
|
|
||||||
return canvas.toDataURL("image/webp", quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const { get } = useUser();
|
|
||||||
const { load } = useStorage();
|
|
||||||
const { send } = useMTP();
|
|
||||||
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(() => {
|
|
||||||
const fetchUser = async () => {
|
|
||||||
const user = await get(await load("user_id"));
|
|
||||||
setCurrentUser(user);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUser();
|
|
||||||
}, [load, get]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser || draftInitializedRef.current) return;
|
|
||||||
|
|
||||||
setDraftUser(currentUser);
|
|
||||||
draftInitializedRef.current = true;
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
const handleAvatarUpload = async (file: File) => {
|
|
||||||
const final = await prepImage(file);
|
|
||||||
updateDraftUser((prev) => ({ ...prev, avatar: final }));
|
|
||||||
if (avatarUploadRef.current) {
|
|
||||||
avatarUploadRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return currentUser ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={avatarUploadRef}
|
|
||||||
hidden
|
|
||||||
onChange={(e) =>
|
|
||||||
e.target.files?.[0] && handleAvatarUpload(e.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((prev) => {
|
|
||||||
return { ...prev, 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((prev) => ({
|
|
||||||
...prev,
|
|
||||||
display: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Display Name"
|
|
||||||
value={draftUser.Display || ""}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateDraftUser((prev) => ({
|
|
||||||
...prev,
|
|
||||||
username: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Username"
|
|
||||||
value={draftUser.Username || ""}
|
|
||||||
/>
|
|
||||||
<MDInput
|
|
||||||
styled
|
|
||||||
paddingY="4px"
|
|
||||||
paddingX="10px"
|
|
||||||
fontSize=".875rem"
|
|
||||||
placeholder="About Me"
|
|
||||||
setValue={(value) =>
|
|
||||||
updateDraftUser((prev) => ({ ...prev, 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 (err) {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("Failed to update profile: " + err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>Loading...</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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, setForcedForcedOmikronUrl] = useState("");
|
|
||||||
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
|
|
||||||
useState("");
|
|
||||||
const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
|
|
||||||
useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load("omega_url").then((value) => {
|
|
||||||
setDraftOmegaUrl(value);
|
|
||||||
setCurrentOmegaUrl(value);
|
|
||||||
});
|
|
||||||
load("forced_omikron_url").then((value) => {
|
|
||||||
setDraftForcedOmikronUrl(value || "");
|
|
||||||
setForcedForcedOmikronUrl(value || "");
|
|
||||||
});
|
|
||||||
load("forced_omikron_public_key").then((value) => {
|
|
||||||
setDraftForcedOmikronPublicKey(value || "");
|
|
||||||
setForcedForcedOmikronPublicKey(value || "");
|
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<p className="text-destructive">
|
|
||||||
It's best not to touch these! 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={(e) => setDraftOmegaUrl(e.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={(e) => setDraftForcedOmikronUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Public Key..."
|
|
||||||
value={draftForcedOmikronPublicKey || ""}
|
|
||||||
onChange={(e) => setDraftForcedOmikronPublicKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
currentForcedOmikronUrl === draftForcedOmikronUrl &&
|
|
||||||
currentForcedOmikronPublicKey === draftForcedOmikronPublicKey
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
save("forced_omikron_url", draftForcedOmikronUrl).then(() =>
|
|
||||||
setForcedForcedOmikronUrl(draftForcedOmikronUrl),
|
|
||||||
);
|
|
||||||
save(
|
|
||||||
"forced_omikron_public_key",
|
|
||||||
draftForcedOmikronPublicKey,
|
|
||||||
).then(() =>
|
|
||||||
setForcedForcedOmikronPublicKey(draftForcedOmikronPublicKey),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -85,6 +85,7 @@ export default defineConfig({
|
||||||
"@tanstack/router-core",
|
"@tanstack/router-core",
|
||||||
"@tanstack/store",
|
"@tanstack/store",
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
|
"@tensamin/settings",
|
||||||
"@tensamin/storage",
|
"@tensamin/storage",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/user",
|
"@tensamin/user",
|
||||||
|
|
@ -138,6 +139,7 @@ export default defineConfig({
|
||||||
"@tensamin/shared",
|
"@tensamin/shared",
|
||||||
"@tensamin/shared/data",
|
"@tensamin/shared/data",
|
||||||
"@tensamin/shared/log",
|
"@tensamin/shared/log",
|
||||||
|
"@tensamin/settings",
|
||||||
"@tensamin/storage",
|
"@tensamin/storage",
|
||||||
"@tensamin/storage/context",
|
"@tensamin/storage/context",
|
||||||
"@tensamin/tauri",
|
"@tensamin/tauri",
|
||||||
|
|
|
||||||
25
packages/cache/package.json
vendored
Normal file
25
packages/cache/package.json
vendored
Normal 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
50
packages/cache/src/helpers.test.ts
vendored
Normal 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
30
packages/cache/src/helpers.ts
vendored
Normal 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
276
packages/cache/src/index.ts
vendored
Normal 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
27
packages/cache/src/schemas.ts
vendored
Normal 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
271
packages/cache/src/sync.tsx
vendored
Normal 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
13
packages/cache/tsconfig.json
vendored
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import type {} from "@tensamin/shared/desktopMedia";
|
||||||
import {
|
import {
|
||||||
type LocalTrack,
|
type LocalTrack,
|
||||||
Room,
|
Room,
|
||||||
|
|
@ -21,46 +22,6 @@ type ScreenShareStoreSetState = (
|
||||||
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
||||||
) => void;
|
) => 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 = {
|
type ScreenShareControllerOptions = {
|
||||||
room: Room;
|
room: Room;
|
||||||
getState: () => ScreenShareStoreState;
|
getState: () => ScreenShareStoreState;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tanstack/pacer": "^0.21.1",
|
"@tanstack/pacer": "^0.21.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-router": "^1.0.0",
|
"@tanstack/react-router": "^1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
|
import { createCache } from "@tensamin/cache";
|
||||||
|
import { secureValueCodec } from "@tensamin/storage/secure";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -158,6 +160,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const [errorDescription, setErrorDescription] = useState("");
|
const [errorDescription, setErrorDescription] = useState("");
|
||||||
|
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
|
const [ownId, setOwnId] = useState(0);
|
||||||
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||||
userId: number;
|
userId: number;
|
||||||
value: Uint8Array | null;
|
value: Uint8Array | null;
|
||||||
|
|
@ -186,6 +189,10 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
return currentChatSecretState.value;
|
return currentChatSecretState.value;
|
||||||
}, [currentChatSecretState, userIdValue]);
|
}, [currentChatSecretState, userIdValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load("user_id").then(setOwnId);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userIdValue) return;
|
if (!userIdValue) return;
|
||||||
|
|
||||||
|
|
@ -328,6 +335,44 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
[load, send],
|
[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(
|
const getMessages = useCallback(
|
||||||
async (amount: number, offset: number) => {
|
async (amount: number, offset: number) => {
|
||||||
if (!currentChatSecret) {
|
if (!currentChatSecret) {
|
||||||
|
|
@ -359,36 +404,22 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Promise.all(
|
return decryptMessages(sorted);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[currentChatSecret, send, userIdValue],
|
[currentChatSecret, decryptMessages, send, userIdValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [ownId, setOwnId] = useState(0);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load("user_id").then(setOwnId);
|
if (!currentChatSecret || !ownId || !userIdValue) return;
|
||||||
}, [load]);
|
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(
|
const editMessage = useCallback(
|
||||||
(sendTime: number, edit: MessageEdit) => {
|
(sendTime: number, edit: MessageEdit) => {
|
||||||
|
|
@ -452,7 +483,6 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
setLiveMessagesState((prev) =>
|
setLiveMessagesState((prev) =>
|
||||||
prev.filter((message) => message.SendTime !== sendTime),
|
prev.filter((message) => message.SendTime !== sendTime),
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"chat-messages",
|
"chat-messages",
|
||||||
String(userIdValue),
|
String(userIdValue),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
- Placeholder image if media fails to load
|
- Placeholder image if media fails to load
|
||||||
- Signature verifications via ed25519 key
|
- Signature verifications via ed25519 key
|
||||||
- Confirmation when exiting with text in the input box.
|
- 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
|
- Drop any unique reactions above 10
|
||||||
- Make the emoji picker not get moved with the mini context menu
|
- Make the emoji picker not get moved with the mini context menu
|
||||||
|
- Add proper loading skeleton
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||||
|
|
||||||
export type PushHandler = (message: ProtocolMessage) => void;
|
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 = {
|
type ContextType = {
|
||||||
send: BoundSendFn;
|
send: BoundSendFn;
|
||||||
subscribe: <T extends keyof Schemas & string>(
|
subscribe: <T extends keyof Schemas & string>(
|
||||||
|
|
@ -67,6 +75,7 @@ type ContextType = {
|
||||||
handler: (message: ProtocolMessage<T>) => void,
|
handler: (message: ProtocolMessage<T>) => void,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
subscribePush: (handler: PushHandler) => () => void;
|
subscribePush: (handler: PushHandler) => () => void;
|
||||||
|
addInterceptor: (interceptor: MTPInterceptor) => () => void;
|
||||||
readyState: number;
|
readyState: number;
|
||||||
ownPing: number;
|
ownPing: number;
|
||||||
iotaPing: number;
|
iotaPing: number;
|
||||||
|
|
@ -153,6 +162,7 @@ export function Provider(props: {
|
||||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||||
|
|
||||||
const connected = readyState === ConnectionState.Connected;
|
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
|
// Custom Pings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected || !identified) {
|
if (!connected || !identified) {
|
||||||
|
|
@ -284,7 +299,13 @@ export function Provider(props: {
|
||||||
|
|
||||||
await MTPClient.init();
|
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 forcedOmikronUrl = await load("forced_omikron_url");
|
||||||
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
||||||
|
|
||||||
|
|
@ -338,7 +359,7 @@ export function Provider(props: {
|
||||||
url,
|
url,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: userId,
|
clientId: userId,
|
||||||
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
keyring: base64ToUint8Array(keyring),
|
||||||
},
|
},
|
||||||
hostPublicKey: omikronPublicKey,
|
hostPublicKey: omikronPublicKey,
|
||||||
descriptor: "client",
|
descriptor: "client",
|
||||||
|
|
@ -396,6 +417,10 @@ export function Provider(props: {
|
||||||
(message) => {
|
(message) => {
|
||||||
try {
|
try {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
if (message.type.startsWith("Error")) {
|
||||||
|
reject(new Error(`Authentication failed: ${message.type}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolve(validateResponse("IdentificationResponse", message));
|
resolve(validateResponse("IdentificationResponse", message));
|
||||||
} catch (authPayloadError) {
|
} catch (authPayloadError) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
|
@ -563,7 +588,15 @@ export function Provider(props: {
|
||||||
const sendQueued: BoundSendFn = useMemo(
|
const sendQueued: BoundSendFn = useMemo(
|
||||||
() => async (type, data, options) => {
|
() => async (type, data, options) => {
|
||||||
const mtp = await mtpRef.get();
|
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],
|
[mtpRef],
|
||||||
);
|
);
|
||||||
|
|
@ -574,6 +607,7 @@ export function Provider(props: {
|
||||||
send: sendQueued,
|
send: sendQueued,
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
|
addInterceptor,
|
||||||
readyState,
|
readyState,
|
||||||
ownPing,
|
ownPing,
|
||||||
iotaPing,
|
iotaPing,
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,8 @@
|
||||||
export { Provider, useMTP } from "./context";
|
export { Provider, useMTP } from "./context";
|
||||||
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
|
export type {
|
||||||
|
BoundSendFn,
|
||||||
|
MTPExchange,
|
||||||
|
MTPInterceptor,
|
||||||
|
PushHandler,
|
||||||
|
ProtocolMessage,
|
||||||
|
} from "./context";
|
||||||
|
|
|
||||||
30
packages/settings/package.json
Normal file
30
packages/settings/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,10 +26,7 @@ type ListStorageKey = {
|
||||||
type ListStorageItem<K extends ListStorageKey> =
|
type ListStorageItem<K extends ListStorageKey> =
|
||||||
Storage[K] extends Array<infer Item> ? Item : never;
|
Storage[K] extends Array<infer Item> ? Item : never;
|
||||||
|
|
||||||
export function Switch({
|
export function Switch({ label, id }: {
|
||||||
label,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -42,23 +39,16 @@ export function Switch({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<UISwitch
|
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => {
|
||||||
id={id}
|
setValue(nextValue);
|
||||||
checked={value}
|
save(id, nextValue);
|
||||||
onCheckedChange={(value) => {
|
}} />
|
||||||
setValue(value);
|
|
||||||
save(id, value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function List<K extends ListStorageKey>({
|
export function List<K extends ListStorageKey>({ label, id }: {
|
||||||
label,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: K;
|
id: K;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -78,30 +68,20 @@ export function List<K extends ListStorageKey>({
|
||||||
|
|
||||||
const toStorageItem = (value: string): ListStorageItem<K> => {
|
const toStorageItem = (value: string): ListStorageItem<K> => {
|
||||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
const referenceItem = items[0] ?? storageDefaults[id][0];
|
||||||
|
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
|
||||||
if (typeof referenceItem === "number") {
|
|
||||||
return Number(value) as ListStorageItem<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value as ListStorageItem<K>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItem = () => {
|
const addItem = () => {
|
||||||
const trimmedValue = inputValue.trim();
|
const trimmedValue = inputValue.trim();
|
||||||
if (!trimmedValue) return;
|
if (!trimmedValue) return;
|
||||||
|
|
||||||
const nextItem = toStorageItem(trimmedValue);
|
const nextItem = toStorageItem(trimmedValue);
|
||||||
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
||||||
|
|
||||||
persistItems([...items, nextItem] as Storage[K]);
|
persistItems([...items, nextItem] as Storage[K]);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItems = (indexes: Set<number>) => {
|
const deleteItems = (indexes: Set<number>) => {
|
||||||
const nextItems = items.filter(
|
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K];
|
||||||
(_, index) => !indexes.has(index),
|
|
||||||
) as Storage[K];
|
|
||||||
|
|
||||||
setItems(nextItems);
|
setItems(nextItems);
|
||||||
setSelectedItems(new Set());
|
setSelectedItems(new Set());
|
||||||
save(id, nextItems);
|
save(id, nextItems);
|
||||||
|
|
@ -112,13 +92,9 @@ export function List<K extends ListStorageKey>({
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Input
|
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => {
|
||||||
value={inputValue}
|
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") addItem();
|
if (event.key === "Enter") addItem();
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
<Button onClick={addItem}>Add item</Button>
|
<Button onClick={addItem}>Add item</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
|
|
@ -126,45 +102,19 @@ export function List<K extends ListStorageKey>({
|
||||||
const labelId = `${String(id)}-${index}`;
|
const labelId = `${String(id)}-${index}`;
|
||||||
const selected = selectedItems.has(index);
|
const selected = selectedItems.has(index);
|
||||||
const deletingSelectedItems = selectedItems.size > 1;
|
const deletingSelectedItems = selectedItems.size > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={`${String(item)}-${index}`}>
|
<ContextMenu key={`${String(item)}-${index}`}>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
||||||
render={
|
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => {
|
||||||
<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);
|
const nextSelected = new Set(previous);
|
||||||
|
if (checked) nextSelected.add(index);
|
||||||
if (checked) {
|
else nextSelected.delete(index);
|
||||||
nextSelected.add(index);
|
|
||||||
} else {
|
|
||||||
nextSelected.delete(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextSelected;
|
return nextSelected;
|
||||||
});
|
})} />
|
||||||
}}
|
<Label htmlFor={labelId}>{String(item)}</Label><div />
|
||||||
/>
|
</div>} />
|
||||||
<Label htmlFor={labelId}>{String(item)}</Label>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}>
|
||||||
variant="destructive"
|
|
||||||
onClick={() =>
|
|
||||||
deleteItems(
|
|
||||||
deletingSelectedItems
|
|
||||||
? selectedItems
|
|
||||||
: new Set([index]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
24
packages/settings/src/index.tsx
Normal file
24
packages/settings/src/index.tsx
Normal 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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
import { Button, ClearStorageButton } from "@tensamin/ui";
|
|
||||||
|
|
||||||
import options from "@tensamin/shared/settings";
|
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
|
||||||
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
|
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui";
|
||||||
|
|
||||||
export default function Screen() {
|
import { settingsNavigation } from "./manifest";
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
{!isMobile && <SettingsSidebar />}
|
{!isMobile && <SettingsSidebar />}
|
||||||
|
|
||||||
{/* Page */}
|
|
||||||
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
|
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{location.pathname
|
{location.pathname
|
||||||
.split("/")
|
.split("/")
|
||||||
.pop()
|
.pop()
|
||||||
?.replace(/-/g, " ")
|
?.replace(/-/g, " ")
|
||||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
.replace(/\b\w/g, (letter) => letter.toUpperCase())}
|
||||||
</h1>
|
</h1>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -28,44 +25,31 @@ export default function Screen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSidebar() {
|
export function SettingsSidebar() {
|
||||||
const settingsOptions = options as Record<
|
|
||||||
string,
|
|
||||||
Record<string, Record<string, unknown>>
|
|
||||||
>;
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isMobile
|
isMobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||||
? "w-full p-1"
|
|
||||||
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
|
||||||
"flex flex-col gap-6",
|
"flex flex-col gap-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Settings */}
|
{categories.map((category) => (
|
||||||
{Object.keys(settingsOptions).map((category) => (
|
|
||||||
// Category
|
|
||||||
<div key={category} className="flex flex-col gap-2">
|
<div key={category} className="flex flex-col gap-2">
|
||||||
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
||||||
{Object.keys(settingsOptions[category]).map((page) => (
|
{settingsNavigation
|
||||||
// Page
|
.filter((page) => page.category === category)
|
||||||
<div key={page}>
|
.map((page) => (
|
||||||
<Button
|
<Button
|
||||||
|
key={page.path}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => navigate({ to: `/settings/${page.path}` })}
|
||||||
navigate({
|
|
||||||
to: "/settings/" + page.toLowerCase(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(page as string).charAt(0).toUpperCase() +
|
{page.label}
|
||||||
(page as string).slice(1)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
37
packages/settings/src/manifest.ts
Normal file
37
packages/settings/src/manifest.ts
Normal 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 !== "/",
|
||||||
|
);
|
||||||
101
packages/settings/src/pages/cache.tsx
Normal file
101
packages/settings/src/pages/cache.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { List, Switch } from "@/features/settings/components";
|
|
||||||
import { Button, Kbd } from "@tensamin/ui";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import { storageDefaults } from "@tensamin/shared/data";
|
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() {
|
export default function Page() {
|
||||||
const { save } = useStorage();
|
const { save } = useStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -33,12 +32,14 @@ export default function Page() {
|
||||||
Trusted embed domains can get your IP-Address! Only add domains if you
|
Trusted embed domains can get your IP-Address! Only add domains if you
|
||||||
really trust them!
|
really trust them!
|
||||||
</p>
|
</p>
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void save("reactions", storageDefaults.reactions)}
|
onClick={() => void save("reactions", storageDefaults.reactions)}
|
||||||
>
|
>
|
||||||
Reset Emoji Ranks
|
Reset Emoji Ranks
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { SettingsSidebar } from "@/features/settings/layout";
|
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
import { SettingsSidebar } from "../layout";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
32
packages/settings/src/pages/licenses.tsx
Normal file
32
packages/settings/src/pages/licenses.tsx
Normal 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>;
|
||||||
|
}
|
||||||
67
packages/settings/src/pages/profile.tsx
Normal file
67
packages/settings/src/pages/profile.tsx
Normal 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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
25
packages/settings/src/pages/security.tsx
Normal file
25
packages/settings/src/pages/security.tsx
Normal 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>;
|
||||||
|
}
|
||||||
5
packages/settings/src/pages/theme.tsx
Normal file
5
packages/settings/src/pages/theme.tsx
Normal 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>;
|
||||||
|
}
|
||||||
14
packages/settings/tsconfig.json
Normal file
14
packages/settings/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"./data": "./src/data.ts",
|
"./data": "./src/data.ts",
|
||||||
"./desktopMedia": "./src/desktopMedia.tsx",
|
"./desktopMedia": "./src/desktopMedia.tsx",
|
||||||
"./log": "./src/log.tsx",
|
"./log": "./src/log.tsx",
|
||||||
|
"./indexedDb": "./src/indexedDb.ts",
|
||||||
"./settings": "./src/settings.ts",
|
"./settings": "./src/settings.ts",
|
||||||
"./features/legal/schema": "./src/features/legal/schema.ts",
|
"./features/legal/schema": "./src/features/legal/schema.ts",
|
||||||
"./features/conversation/schema": "./src/features/conversation/schema.ts"
|
"./features/conversation/schema": "./src/features/conversation/schema.ts"
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,8 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
legal_docs: z.infer<typeof legalDocsSchema>;
|
legal_docs: z.infer<typeof legalDocsSchema>;
|
||||||
cached_contacts: Contacts;
|
cached_contacts: Contacts;
|
||||||
cached_communities: Communities;
|
cached_communities: Communities;
|
||||||
|
cache_contacts: number;
|
||||||
|
cache_messages_per_chat: number;
|
||||||
omega_url: string;
|
omega_url: string;
|
||||||
forced_omikron_url: string | undefined;
|
forced_omikron_url: string | undefined;
|
||||||
forced_omikron_public_key: string | undefined;
|
forced_omikron_public_key: string | undefined;
|
||||||
|
|
@ -475,6 +477,8 @@ export const storageDefaults: Storage = {
|
||||||
},
|
},
|
||||||
cached_contacts: [],
|
cached_contacts: [],
|
||||||
cached_communities: [],
|
cached_communities: [],
|
||||||
|
cache_contacts: 5,
|
||||||
|
cache_messages_per_chat: 20,
|
||||||
omega_url: "https://omega.tensamin.net",
|
omega_url: "https://omega.tensamin.net",
|
||||||
forced_omikron_url: undefined,
|
forced_omikron_url: undefined,
|
||||||
forced_omikron_public_key: undefined,
|
forced_omikron_public_key: undefined,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ type ElectronDesktopApi = {
|
||||||
iconDataUrl?: string;
|
iconDataUrl?: string;
|
||||||
}) => Promise<void>;
|
}) => 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 {
|
declare global {
|
||||||
|
|
|
||||||
98
packages/shared/src/indexedDb.ts
Normal file
98
packages/shared/src/indexedDb.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ const settings = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
|
cache: {},
|
||||||
theme: {},
|
theme: {},
|
||||||
licenses: {},
|
licenses: {},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
"./session": "./src/session.tsx",
|
"./session": "./src/session.tsx",
|
||||||
"./context": "./src/context.tsx",
|
"./context": "./src/context.tsx",
|
||||||
"./indexed-db": "./src/indexed-db.ts"
|
"./secure": "./src/secure.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,32 @@ import {
|
||||||
type Storage as StorageSchema,
|
type Storage as StorageSchema,
|
||||||
storageDefaults as defaults,
|
storageDefaults as defaults,
|
||||||
} from "@tensamin/shared/data";
|
} 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 { ErrorScreen } from "@tensamin/ui";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import {
|
||||||
|
decodeSecureValue,
|
||||||
|
encodeSecureValue,
|
||||||
|
getSecureStorageStatus,
|
||||||
|
isSecureEnvelope,
|
||||||
|
type SecureStorageStatus,
|
||||||
|
} from "./secure";
|
||||||
|
|
||||||
|
export type SaveOptions = { secure?: boolean };
|
||||||
|
|
||||||
interface StorageContextValue {
|
interface StorageContextValue {
|
||||||
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||||
save<K extends keyof StorageSchema>(
|
save<K extends keyof StorageSchema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: StorageSchema[K],
|
value: StorageSchema[K],
|
||||||
|
options?: SaveOptions,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
clear: () => Promise<void>;
|
clear: () => Promise<void>;
|
||||||
|
secureStorage: SecureStorageStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StorageContext = React.createContext<StorageContextValue | undefined>(
|
const StorageContext = React.createContext<StorageContextValue | undefined>(
|
||||||
|
|
@ -30,96 +45,151 @@ const isIndexedDBSupported = typeof indexedDB !== "undefined";
|
||||||
export default function StorageProvider(props: { children: React.ReactNode }) {
|
export default function StorageProvider(props: { children: React.ReactNode }) {
|
||||||
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
|
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
|
||||||
const storageRef = React.useRef(storage);
|
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 [error, setError] = React.useState("");
|
||||||
const [errorDescription, setErrorDescription] = React.useState("");
|
const [errorDescription, setErrorDescription] = React.useState("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
storageRef.current = storage;
|
void getSecureStorageStatus().then((status) => {
|
||||||
}, [storage]);
|
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>(
|
async <K extends keyof StorageSchema>(
|
||||||
key: K,
|
key: K,
|
||||||
): Promise<StorageSchema[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]>;
|
||||||
|
|
||||||
|
const generation = generations.current.get(key) ?? 0;
|
||||||
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
stored = await getEntry(key);
|
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) {
|
} catch (err) {
|
||||||
setError("Failed to load data");
|
setError("Failed to load data");
|
||||||
setErrorDescription(
|
setErrorDescription(
|
||||||
"An error occurred while loading data from IndexedDB. Please try again.",
|
"An error occurred while loading local data. Please reload and try again.",
|
||||||
);
|
);
|
||||||
log(0, "Storage", "red", err);
|
log(0, "Storage", "red", err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loadPromises.current.delete(key);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
if (stored !== undefined) {
|
loadPromises.current.set(
|
||||||
setStorage((prev) => ({ ...prev, [key]: stored }));
|
key,
|
||||||
return stored;
|
request as Promise<StorageSchema[keyof StorageSchema]>,
|
||||||
}
|
);
|
||||||
|
return request;
|
||||||
return defaults[key];
|
|
||||||
},
|
},
|
||||||
[],
|
[commit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveIO = React.useCallback(
|
const save = React.useCallback(
|
||||||
async <K extends keyof StorageSchema>(
|
async <K extends keyof StorageSchema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: StorageSchema[K],
|
value: StorageSchema[K],
|
||||||
|
options: SaveOptions = {},
|
||||||
): Promise<void> => {
|
): 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])) {
|
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||||
await deleteEntry(key);
|
if (desktopStorage?.delete)
|
||||||
setStorage((prev) => ({ ...prev, [key]: defaults[key] }));
|
await desktopStorage.delete(String(key)).catch(() => undefined);
|
||||||
|
await deleteDatabaseEntry("storage", key);
|
||||||
|
commit(key, defaults[key]);
|
||||||
} else {
|
} else {
|
||||||
await setEntry(key, value);
|
const status = options.secure
|
||||||
setStorage((prev) => ({ ...prev, [key]: value }));
|
? (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>(
|
const value = React.useMemo<StorageContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
async load<K extends keyof StorageSchema>(
|
load,
|
||||||
key: K,
|
save,
|
||||||
): Promise<StorageSchema[K]> {
|
clear,
|
||||||
const current = storageRef.current[key];
|
secureStorage,
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[loadIO, saveIO],
|
[clear, load, save, secureStorage],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// @ts-expect-error development utility
|
|
||||||
window.save = value.save;
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
if (error !== "" && errorDescription !== "") {
|
if (error !== "" && errorDescription !== "") {
|
||||||
return <ErrorScreen error={error} description={errorDescription} />;
|
return <ErrorScreen error={error} description={errorDescription} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
142
packages/storage/src/secure.ts
Normal file
142
packages/storage/src/secure.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useStorage } from "./context";
|
import { useStorage } from "./context";
|
||||||
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
|
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
|
||||||
|
import { createCache } from "@tensamin/cache";
|
||||||
|
import { secureValueCodec } from "./secure";
|
||||||
|
|
||||||
interface SessionContextType {
|
interface SessionContextType {
|
||||||
contacts: Contacts;
|
contacts: Contacts;
|
||||||
|
|
@ -21,11 +23,13 @@ interface SessionContextType {
|
||||||
const SessionContext = createContext<SessionContextType | undefined>(undefined);
|
const SessionContext = createContext<SessionContextType | undefined>(undefined);
|
||||||
|
|
||||||
export default function SessionProvider({ children }: { children: ReactNode }) {
|
export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const { freshContacts, freshCommunities, freshCalls } = useMTP();
|
const { freshContacts, freshCommunities, freshCalls, contextReady } =
|
||||||
|
useMTP();
|
||||||
const { load, save } = useStorage();
|
const { load, save } = useStorage();
|
||||||
const [contacts, setContacts] = useState<Contacts>([]);
|
const [contacts, setContacts] = useState<Contacts>([]);
|
||||||
const [communities, setCommunities] = useState<Communities>([]);
|
const [communities, setCommunities] = useState<Communities>([]);
|
||||||
const [localCalls, setLocalCalls] = useState<Calls>([]);
|
const [localCalls, setLocalCalls] = useState<Calls>([]);
|
||||||
|
const [accountId, setAccountId] = useState<number | null>(null);
|
||||||
const calls = [
|
const calls = [
|
||||||
...freshCalls,
|
...freshCalls,
|
||||||
...localCalls.filter(
|
...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(() => {
|
useEffect(() => {
|
||||||
load("cached_contacts").then((cachedData) => {
|
void load("user_id").then(setAccountId);
|
||||||
setContacts([
|
}, [load]);
|
||||||
...freshContacts,
|
|
||||||
...cachedData.filter(
|
// Cached contacts seed the session, then authenticated server data replaces them.
|
||||||
(item) =>
|
useEffect(() => {
|
||||||
!freshContacts.some((fresh) => fresh.UserId === item.UserId),
|
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) => {
|
load("cached_communities").then((cachedData) => {
|
||||||
if (cachedData && freshCommunities) {
|
if (cachedData && freshCommunities) {
|
||||||
setCommunities([
|
setCommunities([
|
||||||
|
|
@ -57,11 +71,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [load, freshContacts, freshCommunities]);
|
}, [load, freshCommunities]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
save("cached_contacts", contacts);
|
|
||||||
}, [contacts, save]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
save("cached_communities", communities);
|
save("cached_communities", communities);
|
||||||
}, [communities, save]);
|
}, [communities, save]);
|
||||||
|
|
@ -72,8 +82,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
(contact) => contact.UserId === userId,
|
(contact) => contact.UserId === userId,
|
||||||
);
|
);
|
||||||
if (userIndex === -1) return prevContacts;
|
if (userIndex === -1) return prevContacts;
|
||||||
const [user] = prevContacts.splice(userIndex, 1);
|
const user = prevContacts[userIndex];
|
||||||
return [user, ...prevContacts];
|
return [
|
||||||
|
user,
|
||||||
|
...prevContacts.slice(0, userIndex),
|
||||||
|
...prevContacts.slice(userIndex + 1),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
|
|
||||||
import { mtp as schemas } from "@tensamin/shared/data";
|
import { mtp as schemas } from "@tensamin/shared/data";
|
||||||
import type z from "zod";
|
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>;
|
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 { 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.
|
* Executes get.
|
||||||
|
|
@ -36,17 +48,17 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
||||||
throw new Error("userId is required");
|
throw new Error("userId is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedUser = storageRef.current[userId];
|
|
||||||
if (cachedUser !== undefined) {
|
|
||||||
return cachedUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUser = pendingRef.current[userId];
|
const pendingUser = pendingRef.current[userId];
|
||||||
if (pendingUser !== undefined) {
|
if (pendingUser !== undefined) {
|
||||||
return pendingUser;
|
return pendingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
|
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 userData = await send("GetUserData", { UserId: userId });
|
||||||
const user = {
|
const user = {
|
||||||
...userData.data,
|
...userData.data,
|
||||||
|
|
@ -54,9 +66,12 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
||||||
? `data:image/webp;base64,${atob(userData.data.Avatar)}`
|
? `data:image/webp;base64,${atob(userData.data.Avatar)}`
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
storageRef.current[userId] = user;
|
storageRef.current[userId] = user;
|
||||||
return user;
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
if (cached) return cached;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
pendingRef.current[userId] = request;
|
pendingRef.current[userId] = request;
|
||||||
|
|
@ -67,9 +82,14 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
||||||
delete pendingRef.current[userId];
|
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 (
|
return (
|
||||||
<UserContext.Provider value={{ get }}>
|
<UserContext.Provider value={{ get }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
|
|
@ -236,6 +236,9 @@ importers:
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.11.1
|
version: 2.11.1
|
||||||
|
'@tensamin/cache':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/cache
|
||||||
'@tensamin/call':
|
'@tensamin/call':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/call
|
version: link:../../packages/call
|
||||||
|
|
@ -254,6 +257,9 @@ importers:
|
||||||
'@tensamin/notifications':
|
'@tensamin/notifications':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/notifications
|
version: link:../../packages/notifications
|
||||||
|
'@tensamin/settings':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/settings
|
||||||
'@tensamin/shared':
|
'@tensamin/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
|
@ -499,6 +505,24 @@ importers:
|
||||||
specifier: ^8.0.10
|
specifier: ^8.0.10
|
||||||
version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)
|
version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
|
packages/cache:
|
||||||
|
dependencies:
|
||||||
|
'@tensamin/mtp':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../mtp
|
||||||
|
'@tensamin/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
'@tensamin/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../storage
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.4.3
|
||||||
|
|
||||||
packages/call:
|
packages/call:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@livekit/components-react':
|
'@livekit/components-react':
|
||||||
|
|
@ -567,6 +591,9 @@ importers:
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tensamin/cache':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../cache
|
||||||
'@tensamin/crypto':
|
'@tensamin/crypto':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../crypto
|
version: link:../crypto
|
||||||
|
|
@ -731,6 +758,46 @@ importers:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
|
|
||||||
|
packages/settings:
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/react-router':
|
||||||
|
specifier: ^1.169.1
|
||||||
|
version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tensamin/cache':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../cache
|
||||||
|
'@tensamin/markdown':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../markdown
|
||||||
|
'@tensamin/mtp':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../mtp
|
||||||
|
'@tensamin/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
'@tensamin/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../storage
|
||||||
|
'@tensamin/ui':
|
||||||
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
||||||
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
|
'@tensamin/user':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../user
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.23.0(react@19.2.7)
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7(react@19.2.7)
|
||||||
|
devDependencies:
|
||||||
|
vite:
|
||||||
|
specifier: ^8.0.10
|
||||||
|
version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
|
|
@ -751,6 +818,9 @@ importers:
|
||||||
|
|
||||||
packages/storage:
|
packages/storage:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tensamin/cache':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../cache
|
||||||
'@tensamin/mtp':
|
'@tensamin/mtp':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../mtp
|
version: link:../mtp
|
||||||
|
|
@ -808,6 +878,9 @@ importers:
|
||||||
|
|
||||||
packages/user:
|
packages/user:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tensamin/cache':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../cache
|
||||||
'@tensamin/mtp':
|
'@tensamin/mtp':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../mtp
|
version: link:../mtp
|
||||||
|
|
|
||||||
1
todo.md
1
todo.md
|
|
@ -1,5 +1,4 @@
|
||||||
- Move legal to extra onboarding package
|
- Move legal to extra onboarding package
|
||||||
- Add a bunch of tests
|
- Add a bunch of tests
|
||||||
- Add packages/cache/ to handle caching
|
|
||||||
- Add packages/hotkeys/
|
- Add packages/hotkeys/
|
||||||
- Full accessability
|
- Full accessability
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue