TTP -> MTP, A lot of other stuff #21
72 changed files with 6986 additions and 6841 deletions
Merge origin/dev
commit
fd0e06b71e
|
|
@ -58,8 +58,12 @@
|
|||
"to": "web"
|
||||
},
|
||||
{
|
||||
"from": "build/icons/icon.png",
|
||||
"to": "icons/icon.png"
|
||||
"from": "build/icons",
|
||||
"to": "icons",
|
||||
"filter": [
|
||||
"32x32.png",
|
||||
"icon.png"
|
||||
]
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,18 @@ import {
|
|||
import { checkForUpdates } from "./updates.js";
|
||||
import {
|
||||
ipcChannels,
|
||||
type DesktopCallStatus,
|
||||
type DesktopScreenShareAudioOutput,
|
||||
type DesktopScreenShareCapabilities,
|
||||
} from "../shared/ipc.js";
|
||||
import { initTray } 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 verbose = process.argv.includes("--verbose");
|
||||
|
|
@ -189,6 +197,37 @@ function registerIpc() {
|
|||
);
|
||||
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||
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) => {
|
||||
if (
|
||||
typeof status !== "object" ||
|
||||
status === null ||
|
||||
typeof (status as DesktopCallStatus).inCall !== "boolean" ||
|
||||
typeof (status as DesktopCallStatus).speaking !== "boolean" ||
|
||||
((status as DesktopCallStatus).iconDataUrl !== undefined &&
|
||||
(typeof (status as DesktopCallStatus).iconDataUrl !== "string" ||
|
||||
!(status as DesktopCallStatus).iconDataUrl?.startsWith(
|
||||
"data:image/png;base64,",
|
||||
) ||
|
||||
(status as DesktopCallStatus).iconDataUrl!.length > 16_384))
|
||||
) {
|
||||
throw new Error("Invalid call status.");
|
||||
}
|
||||
|
||||
const { inCall, iconDataUrl } = status as DesktopCallStatus;
|
||||
setTrayCallStatus(inCall, iconDataUrl);
|
||||
});
|
||||
ipcMain.handle(ipcChannels.minimizeWindow, () => {
|
||||
verboseLog("window:minimize");
|
||||
mainWindow?.minimize();
|
||||
|
|
@ -311,7 +350,7 @@ async function start() {
|
|||
verboseLog("app ready");
|
||||
registerIpc();
|
||||
registerDisplayMediaHandler();
|
||||
initTray();
|
||||
initTray(() => mainWindow);
|
||||
await createWindow();
|
||||
}
|
||||
|
||||
|
|
|
|||
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];
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Tray, Menu } from "electron";
|
||||
import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -7,19 +7,56 @@ let tray: Tray | null = null;
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export function initTray() {
|
||||
const iconPath = path.join(__dirname, "../../build/icons/32x32.png");
|
||||
function getTrayIconPath(filename: string) {
|
||||
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
|
||||
return path.resolve(__dirname, "../../build/icons", filename);
|
||||
}
|
||||
|
||||
tray = new Tray(iconPath); // keep reference alive
|
||||
export function setTrayCallStatus(
|
||||
inCall: boolean,
|
||||
iconDataUrl?: string,
|
||||
) {
|
||||
if (!tray) return;
|
||||
|
||||
if (inCall && iconDataUrl) {
|
||||
const image = nativeImage.createFromDataURL(iconDataUrl);
|
||||
if (!image.isEmpty()) {
|
||||
tray.setImage(image);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tray.setImage(getTrayIconPath("32x32.png"));
|
||||
}
|
||||
|
||||
export function initTray(getMainWindow: () => BrowserWindow | null) {
|
||||
tray = new Tray(getTrayIconPath("32x32.png")); // keep reference alive
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: "Restart", type: "normal" },
|
||||
{ label: "Quit", type: "normal" },
|
||||
{
|
||||
label: "Restart",
|
||||
type: "normal",
|
||||
click: () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
{ label: "Quit", type: "normal", click: () => app.quit() },
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("click", (event) => {
|
||||
console.log(event);
|
||||
tray.on("click", () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
|
||||
import {
|
||||
ipcChannels,
|
||||
type DesktopCallStatus,
|
||||
type DesktopScreenShareSource,
|
||||
secureStorageLimits,
|
||||
} from "../shared/ipc.js";
|
||||
|
||||
function windowAction(channel: string) {
|
||||
return () => ipcRenderer.invoke(channel);
|
||||
}
|
||||
|
||||
function validKey(key: string) {
|
||||
return (
|
||||
typeof key === "string" &&
|
||||
key.length > 0 &&
|
||||
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
|
||||
);
|
||||
}
|
||||
|
||||
const desktopApi = {
|
||||
media: {
|
||||
listScreenShareSources: () =>
|
||||
|
|
@ -27,6 +40,39 @@ const desktopApi = {
|
|||
updates: {
|
||||
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||
},
|
||||
call: {
|
||||
setStatus: (status: DesktopCallStatus) => {
|
||||
if (
|
||||
typeof status?.inCall !== "boolean" ||
|
||||
typeof status?.speaking !== "boolean" ||
|
||||
(status.iconDataUrl !== undefined &&
|
||||
(typeof status.iconDataUrl !== "string" ||
|
||||
!status.iconDataUrl.startsWith("data:image/png;base64,")))
|
||||
) {
|
||||
return Promise.reject(new Error("Invalid call 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: {
|
||||
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
||||
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,22 @@ export type DesktopScreenShareCapabilities = {
|
|||
hasReliableSystemAudio: boolean;
|
||||
};
|
||||
|
||||
export type DesktopCallStatus = {
|
||||
inCall: boolean;
|
||||
speaking: boolean;
|
||||
iconDataUrl?: string;
|
||||
};
|
||||
|
||||
export type DesktopSecureStorageStatus = {
|
||||
available: boolean;
|
||||
backend: string | null;
|
||||
};
|
||||
|
||||
export const secureStorageLimits = {
|
||||
maxKeyBytes: 256,
|
||||
maxValueBytes: 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export type ReleaseArtifact = {
|
||||
name: string;
|
||||
platform: string;
|
||||
|
|
@ -55,4 +71,10 @@ export const ipcChannels = {
|
|||
closeWindow: "window:close",
|
||||
getVersion: "app:getVersion",
|
||||
checkForUpdates: "updates:checkForUpdates",
|
||||
setCallStatus: "call:setStatus",
|
||||
getSecureStorageStatus: "secureStorage:getStatus",
|
||||
loadSecureStorage: "secureStorage:load",
|
||||
saveSecureStorage: "secureStorage:save",
|
||||
deleteSecureStorage: "secureStorage:delete",
|
||||
clearSecureStorage: "secureStorage:clear",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@
|
|||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/settings": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
<House className="size-4.5" />
|
||||
</Button>
|
||||
<Button
|
||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
||||
onClick={() => navigate({ to: "/settings" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
|
|
@ -195,7 +194,6 @@ export function MobileNavbar() {
|
|||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
||||
navigate({ to: "/settings" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,30 @@ export default function Form() {
|
|||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
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
|
||||
const processDroppedFile = React.useCallback(
|
||||
|
|
@ -93,20 +117,13 @@ export default function Form() {
|
|||
const raw = await file.text();
|
||||
const parsed = parseTuFileContent(raw);
|
||||
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", parsed.userId);
|
||||
await save("mtp_keyring", parsed.privateKey);
|
||||
if (parsed.domain) {
|
||||
await save("omega_url", `https://${parsed.domain}/`);
|
||||
}
|
||||
|
||||
location.href = "/";
|
||||
await persistLogin(parsed.userId, parsed.privateKey, parsed.domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to load file");
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[persistLogin],
|
||||
);
|
||||
|
||||
// Handle .tu files
|
||||
|
|
@ -236,20 +253,13 @@ export default function Form() {
|
|||
|
||||
const user = parse.data;
|
||||
|
||||
await save("session_id", Date.now());
|
||||
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 = "/";
|
||||
await persistLogin(user.user_id, inputParse.data.mtp_keyring, domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to fetch user data");
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[persistLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -269,15 +279,7 @@ export default function Form() {
|
|||
const { userId, privateKey, domain } =
|
||||
parseTuFileContent(decoded);
|
||||
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
await save("mtp_keyring", privateKey);
|
||||
|
||||
if (domain) {
|
||||
await save("omega_url", `https://${domain}/`);
|
||||
}
|
||||
|
||||
location.href = "/";
|
||||
await persistLogin(userId, privateKey, domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to parse QR code data");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import "@tensamin/ui/index.css";
|
|||
import NotFound from "@/routes/404";
|
||||
|
||||
import AppLayout from "@/routes/app/layout";
|
||||
import SettingsLayout from "@/features/settings/layout";
|
||||
import { createSettingsRoute } from "@tensamin/settings";
|
||||
|
||||
import Home from "@/routes/app/home";
|
||||
import ChatScreen from "@tensamin/chat/screen";
|
||||
|
|
@ -23,7 +23,8 @@ import Login from "@/routes/screens/login";
|
|||
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
import ChatContext from "@tensamin/chat/context";
|
||||
import { useInitializeCall } from "@tensamin/call/store";
|
||||
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||
import { Provider as MTPProvider } from "@tensamin/mtp";
|
||||
import UserProvider from "@tensamin/user/context";
|
||||
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
||||
|
|
@ -40,8 +41,10 @@ import Storage from "@tensamin/storage/context";
|
|||
import Session from "@tensamin/storage/session";
|
||||
import Crypto from "@tensamin/crypto/context";
|
||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
import LegalWrapper from "@/features/legal/screen";
|
||||
import CacheSync from "@tensamin/cache/sync";
|
||||
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -70,34 +73,39 @@ window.setLogLevelToMax = () => {
|
|||
function LoginWrapper({ children }: { children: ReactNode }) {
|
||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
const { load } = useStorage();
|
||||
const { load, secureStorage } = useStorage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (secureStorage === null) return;
|
||||
let active = true;
|
||||
|
||||
load("user_id").then((userId) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId !== 0) {
|
||||
Promise.all([load("user_id"), load("mtp_keyring")])
|
||||
.then(([userId, keyring]) => {
|
||||
if (!active) return;
|
||||
if (userId !== 0 && keyring !== "") {
|
||||
setLoggedIn(true);
|
||||
if (location.pathname === "/login") {
|
||||
void navigate({ to: "/", replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoggedIn(false);
|
||||
navigate({
|
||||
void navigate({
|
||||
to: "/login",
|
||||
replace: true,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
log(0, "login", "red", "Failed to load login state", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load, navigate]);
|
||||
}, [load, location.pathname, navigate, secureStorage]);
|
||||
|
||||
if (loggedIn !== true && location.pathname !== "/login") {
|
||||
return null;
|
||||
|
|
@ -255,6 +263,7 @@ function RootShell() {
|
|||
function AppShell() {
|
||||
return (
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
|
|
@ -274,8 +283,94 @@ function AppShell() {
|
|||
);
|
||||
}
|
||||
|
||||
function createCallTrayIcon(color: string, speaking: boolean) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
|
||||
context.globalAlpha = speaking ? 1 : 0.55;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(16, 16, 13, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
if (speaking) {
|
||||
context.globalAlpha = 0.3;
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fill();
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function CallInit() {
|
||||
return useInitializeCall();
|
||||
useInitializeCall();
|
||||
|
||||
const { load } = useStorage();
|
||||
const {
|
||||
themeColor,
|
||||
themePalette,
|
||||
themePrimaryColor,
|
||||
themePolarity,
|
||||
themeTint,
|
||||
themeCustomCss,
|
||||
} = useTheme();
|
||||
const [localUserId, setLocalUserId] = useState(-1);
|
||||
const [primaryColor, setPrimaryColor] = useState("");
|
||||
const inCall = useCall((state) => state.state === "open");
|
||||
const speaking = useIsSpeaking(localUserId);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
load("user_id").then((userId) => {
|
||||
if (active) setLocalUserId(userId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setPrimaryColor(
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--primary")
|
||||
.trim(),
|
||||
);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [
|
||||
themeColor,
|
||||
themeCustomCss,
|
||||
themePalette,
|
||||
themePolarity,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const iconDataUrl = primaryColor
|
||||
? createCallTrayIcon(primaryColor, speaking)
|
||||
: undefined;
|
||||
|
||||
void window.tensaminDesktop?.call
|
||||
?.setStatus?.({
|
||||
inCall,
|
||||
speaking: inCall && speaking,
|
||||
iconDataUrl: inCall ? iconDataUrl : undefined,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to update desktop call status", error);
|
||||
});
|
||||
}, [inCall, primaryColor, speaking]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
@ -295,47 +390,7 @@ const appRoute = createRoute({
|
|||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
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 settingsRoute = createSettingsRoute(appRoute);
|
||||
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
|
|
@ -377,12 +432,7 @@ const loginRoute = createRoute({
|
|||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
chatRoute,
|
||||
callRoute,
|
||||
settingsRoute.addChildren(settingsChildren),
|
||||
]),
|
||||
appRoute.addChildren([homeRoute, chatRoute, callRoute, settingsRoute]),
|
||||
loginRoute,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,16 +16,36 @@ import { useState } from "react";
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
// The page
|
||||
export default function Page() {
|
||||
const isMobile = useIsMobile();
|
||||
const { secureStorage } = useStorage();
|
||||
|
||||
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 />
|
||||
<Button disabled>Add Community</Button>
|
||||
</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,84 +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 [draftMtpUrl, setDraftMtpUrl] = useState("");
|
||||
const [currentMtpUrl, setCurrentMtpUrl] = useState("");
|
||||
|
||||
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
|
||||
const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState("");
|
||||
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
|
||||
useState("");
|
||||
const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
|
||||
useState("");
|
||||
|
||||
useEffect(() => {
|
||||
load("omega_url").then((value) => {
|
||||
setDraftMtpUrl(value);
|
||||
setCurrentMtpUrl(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={draftMtpUrl}
|
||||
onChange={(e) => setDraftMtpUrl(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={currentMtpUrl === draftMtpUrl}
|
||||
onClick={() => {
|
||||
save("omega_url", draftMtpUrl);
|
||||
}}
|
||||
>
|
||||
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);
|
||||
save("forced_omikron_public_key", 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/store",
|
||||
"@tensamin/crypto",
|
||||
"@tensamin/settings",
|
||||
"@tensamin/storage",
|
||||
"@tensamin/mtp",
|
||||
"@tensamin/user",
|
||||
|
|
@ -138,6 +139,7 @@ export default defineConfig({
|
|||
"@tensamin/shared",
|
||||
"@tensamin/shared/data",
|
||||
"@tensamin/shared/log",
|
||||
"@tensamin/settings",
|
||||
"@tensamin/storage",
|
||||
"@tensamin/storage/context",
|
||||
"@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>;
|
||||
279
packages/cache/src/sync.tsx
vendored
Normal file
279
packages/cache/src/sync.tsx
vendored
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
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 === "MessageDeleteLive") {
|
||||
const deleteData = message.data as {
|
||||
ChatPartnerId: number;
|
||||
SendTime: number;
|
||||
};
|
||||
await removeMessage(deleteData.ChatPartnerId, deleteData.SendTime);
|
||||
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, removeMessage, 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"]
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
"./store": "./src/store.tsx",
|
||||
"./speakingState": "./src/speakingState.ts",
|
||||
"./screen": "./src/screen.tsx",
|
||||
"./utils": "./src/utils.ts",
|
||||
"./sidebarBox": "./src/components/sidebarBox.tsx",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { log } from "@tensamin/shared/log";
|
||||
import type {} from "@tensamin/shared/desktopMedia";
|
||||
import {
|
||||
type LocalTrack,
|
||||
Room,
|
||||
|
|
@ -21,39 +22,6 @@ type ScreenShareStoreSetState = (
|
|||
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
||||
) => void;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
tensaminDesktop?: {
|
||||
media?: {
|
||||
getScreenShareCapabilities?: () => Promise<{
|
||||
runtime?: "electron" | "tauri";
|
||||
platform: "linux" | "macos" | "windows" | "other";
|
||||
showAudioOutputSelector: boolean;
|
||||
showAudioSwitch: boolean;
|
||||
hasReliableSystemAudio: boolean;
|
||||
}>;
|
||||
listScreenShareSources?: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
kind: "screen" | "window";
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
thumbnail?: string | null;
|
||||
}>
|
||||
>;
|
||||
listScreenShareAudioOutputs?: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
}>
|
||||
>;
|
||||
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ScreenShareControllerOptions = {
|
||||
room: Room;
|
||||
getState: () => ScreenShareStoreState;
|
||||
|
|
|
|||
|
|
@ -16,18 +16,20 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tanstack/pacer": "^0.21.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.42.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
|
|
|
|||
29
packages/chat/src/components/emojiPicker.tsx
Normal file
29
packages/chat/src/components/emojiPicker.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||
|
||||
export default function EmojiPicker({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const { ranks } = useEmojiRanks();
|
||||
const emojis = getRecentEmojis(ranks, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1 p-1">
|
||||
{emojis.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
aria-label={`Select ${emoji}`}
|
||||
className="h-10 w-10 p-0"
|
||||
onClick={() => onSelect(emoji)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Emoji shortcode={emoji} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
packages/chat/src/components/emojiRanks.ts
Normal file
95
packages/chat/src/components/emojiRanks.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
|
||||
const RANKS_CHANGED_EVENT = "tensamin-reaction-ranks-changed";
|
||||
let recordQueue = Promise.resolve();
|
||||
|
||||
function normalizeRanks(ranks: Record<string, number>) {
|
||||
return Object.entries(ranks).reduce<Record<string, number>>(
|
||||
(normalized, [value, frequency]) => {
|
||||
const shortcode = normalizeShortcode(value);
|
||||
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized[shortcode] = (normalized[shortcode] ?? 0) + frequency;
|
||||
return normalized;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function rankedEmojis(ranks: Record<string, number>) {
|
||||
return Object.entries(ranks)
|
||||
.filter(
|
||||
([emoji, frequency]) =>
|
||||
normalizeShortcode(emoji) !== undefined &&
|
||||
Number.isFinite(frequency) &&
|
||||
frequency > 0,
|
||||
)
|
||||
.sort(([emojiA, frequencyA], [emojiB, frequencyB]) =>
|
||||
frequencyB === frequencyA
|
||||
? emojiA.localeCompare(emojiB)
|
||||
: frequencyB - frequencyA,
|
||||
)
|
||||
.flatMap(([emoji]) => {
|
||||
const shortcode = normalizeShortcode(emoji);
|
||||
return shortcode ? [shortcode] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecentEmojis(ranks: Record<string, number>, amount: number) {
|
||||
return rankedEmojis(ranks).slice(0, amount);
|
||||
}
|
||||
|
||||
export function useEmojiRanks() {
|
||||
const { load, save } = useStorage();
|
||||
const [ranks, setRanks] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
void load("reactions").then((storedRanks) => {
|
||||
const normalized = normalizeRanks(storedRanks);
|
||||
setRanks(normalized);
|
||||
if (JSON.stringify(normalized) !== JSON.stringify(storedRanks)) {
|
||||
void save("reactions", normalized);
|
||||
}
|
||||
});
|
||||
|
||||
function handleRanksChanged(event: Event) {
|
||||
setRanks((event as CustomEvent<Record<string, number>>).detail);
|
||||
}
|
||||
|
||||
window.addEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||
return () =>
|
||||
window.removeEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||
}, [load, save]);
|
||||
|
||||
return { ranks };
|
||||
}
|
||||
|
||||
export function useRecordEmojiUse() {
|
||||
const { load, save } = useStorage();
|
||||
|
||||
return useCallback(
|
||||
(emoji: string) => {
|
||||
const shortcode = normalizeShortcode(emoji);
|
||||
if (!shortcode) return;
|
||||
|
||||
recordQueue = recordQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const normalized = normalizeRanks(await load("reactions"));
|
||||
const next = {
|
||||
...normalized,
|
||||
[shortcode]: (normalized[shortcode] ?? 0) + 1,
|
||||
};
|
||||
await save("reactions", next);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(RANKS_CHANGED_EVENT, { detail: next }),
|
||||
);
|
||||
});
|
||||
},
|
||||
[load, save],
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
|||
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import GifPicker from "./gifPicker";
|
||||
import EmojiPicker from "./emojiPicker";
|
||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
|
||||
|
|
@ -48,6 +50,9 @@ export default function InputComponent({
|
|||
const { moveUserIdToTop } = useSession();
|
||||
const gifPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const [gifPopoverOpen, setGifPopoverOpen] = useState(false);
|
||||
const [emojiPopoverOpen, setEmojiPopoverOpen] = useState(false);
|
||||
const recordUse = useRecordEmojiUse();
|
||||
const { ranks: emojiFrequencies } = useEmojiRanks();
|
||||
const [gifPopoverSize, setGifPopoverSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -257,6 +262,8 @@ export default function InputComponent({
|
|||
setValue={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
invertEnterBehavior={invertEnterBehavior}
|
||||
emojiFrequencies={emojiFrequencies}
|
||||
onEmojiSelect={recordUse}
|
||||
/>
|
||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -268,9 +275,31 @@ export default function InputComponent({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||
<Popover
|
||||
open={emojiPopoverOpen}
|
||||
onOpenChange={setEmojiPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="Open emoji picker"
|
||||
className="w-9 h-9 p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Laugh size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(shortcode) => {
|
||||
setValue(`${value}${shortcode} `);
|
||||
recordUse(shortcode);
|
||||
setEmojiPopoverOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import * as React from "react";
|
||||
import type { RawMessage } from "../values";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Ellipse,
|
||||
Forward,
|
||||
Laugh,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
|
||||
|
|
@ -18,9 +10,7 @@ import {
|
|||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
Card,
|
||||
cn,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from "@tensamin/ui";
|
||||
import MessageContextMenu from "./messageContextMenu";
|
||||
|
|
@ -31,6 +21,8 @@ import Input from "@tensamin/markdown/input";
|
|||
import { useChat } from "../context";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
import { useRecordEmojiUse } from "./emojiRanks";
|
||||
|
||||
function MessageComponent({
|
||||
grouped,
|
||||
|
|
@ -143,7 +135,15 @@ function MessageComponent({
|
|||
}, [message.Content]);
|
||||
|
||||
// Message editing
|
||||
const { chatSecret, editMessage, userId } = useChat();
|
||||
const {
|
||||
addReaction,
|
||||
chatSecret,
|
||||
editMessage,
|
||||
userId,
|
||||
deleteMessage,
|
||||
removeReaction,
|
||||
} = useChat();
|
||||
const recordUse = useRecordEmojiUse();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.Content);
|
||||
useEffect(() => {
|
||||
|
|
@ -197,6 +197,39 @@ function MessageComponent({
|
|||
],
|
||||
);
|
||||
|
||||
const groupedReactions = Object.entries(
|
||||
(message.Reactions ?? []).reduce<
|
||||
Record<string, { count: number; reactedByMe: boolean }>
|
||||
>((groups, item) => {
|
||||
const reaction = normalizeShortcode(item.Reaction);
|
||||
if (!reaction) return groups;
|
||||
|
||||
const group = groups[reaction] ?? {
|
||||
count: 0,
|
||||
reactedByMe: false,
|
||||
};
|
||||
group.count += 1;
|
||||
group.reactedByMe ||= item.SenderId === ownId;
|
||||
groups[reaction] = group;
|
||||
return groups;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
function toggleReaction(reaction: string) {
|
||||
const reactedByMe = message.Reactions?.some(
|
||||
(item) =>
|
||||
normalizeShortcode(item.Reaction) === reaction &&
|
||||
item.SenderId === ownId,
|
||||
);
|
||||
|
||||
if (reactedByMe) {
|
||||
return removeReaction(message.SendTime, reaction);
|
||||
}
|
||||
|
||||
recordUse(reaction);
|
||||
return addReaction(message.SendTime, reaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// pt-3 is to get a gap between messages
|
||||
|
|
@ -205,9 +238,11 @@ function MessageComponent({
|
|||
{user && message.Content ? (
|
||||
<MessageContextMenu
|
||||
content={message.Content}
|
||||
hideMiniMenu={editing}
|
||||
isOwnMessage={message.SenderId === ownId}
|
||||
messageId={message.SendTime}
|
||||
onReact={toggleReaction}
|
||||
onSetEditing={setEditing}
|
||||
senderId={message.SenderId}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -234,26 +269,11 @@ function MessageComponent({
|
|||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex flex-col group">
|
||||
<Card className="absolute right-4 -top-3 opacity-0 group-hover:opacity-100 flex flex-row justify-end gap-0! z-10 p-0! shadow-lg rounded-lg!">
|
||||
{[0, 1, 2].map((item) => (
|
||||
<Button key={item} variant="ghost" className="h-8 w-8">
|
||||
{item === 0 && "👍"}
|
||||
{item === 1 && "🔥"}
|
||||
{item === 2 && "✅"}
|
||||
</Button>
|
||||
))}
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
<Button variant="ghost" className="h-8 w-8">
|
||||
<Laugh />
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-8 w-8">
|
||||
<Reply />
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-8 w-8">
|
||||
<Forward />
|
||||
</Button>
|
||||
</Card>
|
||||
<div
|
||||
className={cn("flex flex-col group", {
|
||||
"min-w-0 flex-1": editing,
|
||||
})}
|
||||
>
|
||||
{!grouped && (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-medium">{user.Display}</p>
|
||||
|
|
@ -283,7 +303,10 @@ function MessageComponent({
|
|||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Input
|
||||
className="w-full"
|
||||
styled
|
||||
setValue={setEditDraft}
|
||||
value={editDraft}
|
||||
onSubmit={() => {
|
||||
|
|
@ -291,11 +314,65 @@ function MessageComponent({
|
|||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-primary-foreground-alt"
|
||||
onClick={() => {
|
||||
if (editDraft === message.Content) {
|
||||
deleteMessage(message.SendTime);
|
||||
} else {
|
||||
submitEditMessage(editDraft);
|
||||
}
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isValidURL ? (
|
||||
<Media link={message.Content} />
|
||||
) : (
|
||||
<Text value={message.Content} />
|
||||
)}
|
||||
{groupedReactions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||
{groupedReactions.map(
|
||||
([reaction, { count, reactedByMe }]) => (
|
||||
<Button
|
||||
key={reaction}
|
||||
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
||||
className={cn(
|
||||
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
||||
reactedByMe
|
||||
? "border-(--primary-foreground-alt)/40!"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => void toggleReaction(reaction)}
|
||||
size="xs"
|
||||
variant={reactedByMe ? "subtleDefault" : "outline"}
|
||||
>
|
||||
<Emoji className="h-5 w-5" shortcode={reaction} />
|
||||
<span className="text-sm">{count}</span>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
|
@ -315,6 +392,8 @@ export default React.memo(MessageComponent, (prev, next) => {
|
|||
prev.message.MessageState === next.message.MessageState &&
|
||||
prev.message.failed === next.message.failed &&
|
||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||
JSON.stringify(prev.message.Reactions) ===
|
||||
JSON.stringify(next.message.Reactions) &&
|
||||
prev.grouped === next.grouped &&
|
||||
prev.user === next.user
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
cn,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -13,14 +15,36 @@ import {
|
|||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
Forward,
|
||||
Laugh,
|
||||
Plus,
|
||||
Pen,
|
||||
Pin,
|
||||
Reply,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useChat } from "../context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
import EmojiPicker from "./emojiPicker";
|
||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
|
@ -126,48 +150,300 @@ function getMobileMenuComponents({
|
|||
};
|
||||
}
|
||||
|
||||
function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) {
|
||||
return <Item>Cool item</Item>;
|
||||
function ReactionItems({
|
||||
Item,
|
||||
emojis,
|
||||
onMore,
|
||||
onSelect,
|
||||
}: {
|
||||
Item: MenuComponents["Item"];
|
||||
emojis: string[];
|
||||
onMore: () => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{emojis.map((emoji) => (
|
||||
<Item key={emoji} onClick={() => onSelect(emoji)}>
|
||||
<Emoji className="w-4.5 h-4.5" shortcode={emoji} />
|
||||
<span>{emoji}</span>
|
||||
</Item>
|
||||
))}
|
||||
<Item onClick={onMore}>
|
||||
<Plus className="size-4.5" />
|
||||
<span>More reactions</span>
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let shiftPressed = false;
|
||||
const shiftListeners = new Set<() => void>();
|
||||
|
||||
function setShiftPressed(value: boolean) {
|
||||
if (shiftPressed === value) return;
|
||||
|
||||
shiftPressed = value;
|
||||
shiftListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribeToShift(listener: () => void) {
|
||||
shiftListeners.add(listener);
|
||||
|
||||
if (shiftListeners.size === 1) {
|
||||
window.addEventListener("keydown", handleShiftKey);
|
||||
window.addEventListener("keyup", handleShiftKey);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
}
|
||||
|
||||
return () => {
|
||||
shiftListeners.delete(listener);
|
||||
if (shiftListeners.size === 0) {
|
||||
window.removeEventListener("keydown", handleShiftKey);
|
||||
window.removeEventListener("keyup", handleShiftKey);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
shiftPressed = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleShiftKey(event: KeyboardEvent) {
|
||||
setShiftPressed(event.shiftKey);
|
||||
}
|
||||
|
||||
function handleWindowBlur() {
|
||||
setShiftPressed(false);
|
||||
}
|
||||
|
||||
function useShiftPressed() {
|
||||
return useSyncExternalStore(
|
||||
subscribeToShift,
|
||||
() => shiftPressed,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
let activeMiniMenuId: number | null = null;
|
||||
let clearMiniMenuTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const miniMenuListeners = new Set<() => void>();
|
||||
|
||||
function setActiveMiniMenu(messageId: number | null) {
|
||||
if (clearMiniMenuTimeout !== undefined) {
|
||||
clearTimeout(clearMiniMenuTimeout);
|
||||
clearMiniMenuTimeout = undefined;
|
||||
}
|
||||
if (activeMiniMenuId === messageId) return;
|
||||
|
||||
activeMiniMenuId = messageId;
|
||||
miniMenuListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function scheduleMiniMenuClose() {
|
||||
clearMiniMenuTimeout = setTimeout(() => {
|
||||
clearMiniMenuTimeout = undefined;
|
||||
setActiveMiniMenu(null);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function useActiveMiniMenuId() {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
miniMenuListeners.add(listener);
|
||||
return () => miniMenuListeners.delete(listener);
|
||||
},
|
||||
() => activeMiniMenuId,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMenuTooltip({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMessageMenu({
|
||||
isOwnMessage,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenMenu,
|
||||
onOpenPicker,
|
||||
usePickerTrigger,
|
||||
onReact,
|
||||
quickReactions,
|
||||
onReply,
|
||||
shiftIsPressed,
|
||||
}: {
|
||||
isOwnMessage: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onOpenMenu: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
onOpenPicker: () => void;
|
||||
usePickerTrigger: boolean;
|
||||
onReact: (emoji: string) => void;
|
||||
quickReactions: string[];
|
||||
onReply: () => void;
|
||||
shiftIsPressed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-4 -top-3 z-10">
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||
{quickReactions.map((emoji) => (
|
||||
<MiniMenuTooltip
|
||||
key={emoji}
|
||||
label={emoji}
|
||||
children={
|
||||
<Button
|
||||
aria-label={`React with ${emoji}`}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0!"
|
||||
onClick={() => onReact(emoji)}
|
||||
>
|
||||
<Emoji className="h-5 w-5" shortcode={emoji} tooltip={false} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
<MiniMenuTooltip
|
||||
label="Add reaction"
|
||||
children={
|
||||
usePickerTrigger ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={onOpenPicker}
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MiniMenuTooltip
|
||||
label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||
onClick={isOwnMessage ? onEdit : onReply}
|
||||
>
|
||||
{isOwnMessage ? <Pen /> : <Reply />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<MiniMenuTooltip
|
||||
label="Forward message"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label="Forward message"
|
||||
>
|
||||
<Forward />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
{isOwnMessage && shiftIsPressed ? (
|
||||
<MiniMenuTooltip
|
||||
label="Delete message"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive"
|
||||
aria-label="Delete message"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MiniMenuTooltip
|
||||
label="Open message menu"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label="Open message menu"
|
||||
onClick={onOpenMenu}
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageMenuContent({
|
||||
components,
|
||||
content,
|
||||
devEnabled,
|
||||
isOwnMessage,
|
||||
messageId,
|
||||
onAddReaction,
|
||||
reactionEmojis,
|
||||
onReact,
|
||||
showReactionItems = true,
|
||||
onSetEditing,
|
||||
senderId,
|
||||
}: {
|
||||
components: MenuComponents;
|
||||
content: string;
|
||||
devEnabled: boolean;
|
||||
isOwnMessage: boolean;
|
||||
messageId: number;
|
||||
onAddReaction?: () => void | Promise<void>;
|
||||
reactionEmojis: string[];
|
||||
onReact: (emoji: string) => void;
|
||||
showReactionItems?: boolean;
|
||||
onSetEditing: (value: boolean) => void;
|
||||
senderId: number;
|
||||
}) {
|
||||
const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } =
|
||||
components;
|
||||
|
||||
const { load } = useStorage();
|
||||
const { setReplyTo } = useChat();
|
||||
|
||||
const [ownId, setOwnId] = useState(0);
|
||||
useEffect(() => {
|
||||
load("user_id").then(setOwnId);
|
||||
}, [load]);
|
||||
const { deleteMessage, setReplyTo } = useChat();
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<Group>
|
||||
<Sub>
|
||||
<SubTrigger onClick={onAddReaction}>Add Reaction</SubTrigger>
|
||||
<SubTrigger onClick={showReactionItems ? undefined : onAddReaction}>
|
||||
Add Reaction
|
||||
</SubTrigger>
|
||||
{showReactionItems && (
|
||||
<SubContent>
|
||||
<ReactionItems Item={Item} />
|
||||
<ReactionItems
|
||||
Item={Item}
|
||||
emojis={reactionEmojis}
|
||||
onMore={() => void onAddReaction?.()}
|
||||
onSelect={onReact}
|
||||
/>
|
||||
</SubContent>
|
||||
)}
|
||||
</Sub>
|
||||
|
|
@ -183,7 +459,7 @@ function MessageMenuContent({
|
|||
</Group>
|
||||
<Separator />
|
||||
<Group>
|
||||
{senderId === ownId && (
|
||||
{isOwnMessage && (
|
||||
<Item
|
||||
className="flex justify-between"
|
||||
onClick={() => {
|
||||
|
|
@ -205,12 +481,20 @@ function MessageMenuContent({
|
|||
<p>Forward</p> <Forward />
|
||||
</Item>
|
||||
</Group>
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<Separator />
|
||||
<Group>
|
||||
<Item disabled variant="destructive" className="flex justify-between">
|
||||
<Item
|
||||
variant="destructive"
|
||||
className="flex justify-between"
|
||||
onClick={() => deleteMessage(messageId)}
|
||||
>
|
||||
<p>Delete Message</p> <Trash />
|
||||
</Item>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
{devEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
|
@ -231,23 +515,40 @@ function MessageMenuContent({
|
|||
export default function MessageContextMenu({
|
||||
children,
|
||||
content,
|
||||
hideMiniMenu = false,
|
||||
isOwnMessage,
|
||||
messageId,
|
||||
onReact,
|
||||
onSetEditing,
|
||||
senderId,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
content: string;
|
||||
hideMiniMenu?: boolean;
|
||||
isOwnMessage: boolean;
|
||||
messageId: number;
|
||||
onReact: (emoji: string) => void | Promise<void>;
|
||||
onSetEditing: (value: boolean) => void;
|
||||
senderId: number;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const shiftIsPressed = useShiftPressed();
|
||||
const activeMenuId = useActiveMiniMenuId();
|
||||
const { deleteMessage, setReplyTo } = useChat();
|
||||
const devEnabled = useMemo(
|
||||
() => Number(localStorage.getItem("log_level")) >= 3,
|
||||
[],
|
||||
);
|
||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const { ranks } = useEmojiRanks();
|
||||
const quickReactions = getRecentEmojis(ranks, 3);
|
||||
const menuReactions = getRecentEmojis(ranks, 5);
|
||||
|
||||
function selectReaction(emoji: string) {
|
||||
void onReact(emoji);
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
const mainDrawerComponents = useMemo(
|
||||
() =>
|
||||
getMobileMenuComponents({
|
||||
|
|
@ -267,20 +568,60 @@ export default function MessageContextMenu({
|
|||
[],
|
||||
);
|
||||
|
||||
function renderMessage(
|
||||
openMenu: (event: ReactMouseEvent<HTMLElement>) => void,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className="group/message-menu relative w-full"
|
||||
onPointerEnter={() => setActiveMiniMenu(messageId)}
|
||||
onPointerLeave={scheduleMiniMenuClose}
|
||||
>
|
||||
{children}
|
||||
{!isMobile && !hideMiniMenu && activeMenuId === messageId && (
|
||||
<MiniMessageMenu
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onOpenPicker={() => setPickerOpen(true)}
|
||||
usePickerTrigger={!isMobile}
|
||||
onReact={selectReaction}
|
||||
quickReactions={quickReactions}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const child = renderMessage(() => setMainDrawerOpen(true));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer open={mainDrawerOpen} onOpenChange={setMainDrawerOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
{cloneElement(child, {
|
||||
onContextMenu: (event: ReactMouseEvent) => {
|
||||
child.props.onContextMenu?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
event.preventDefault();
|
||||
setMainDrawerOpen(true);
|
||||
},
|
||||
})}
|
||||
<MessageMenuContent
|
||||
components={mainDrawerComponents}
|
||||
content={content}
|
||||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setReactionDrawerOpen(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
showReactionItems={false}
|
||||
onSetEditing={onSetEditing}
|
||||
senderId={senderId}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer open={reactionDrawerOpen} onOpenChange={setReactionDrawerOpen}>
|
||||
|
|
@ -290,7 +631,26 @@ export default function MessageContextMenu({
|
|||
Choose a reaction to add to this message.
|
||||
</DrawerDescription>
|
||||
<div className="p-3!">
|
||||
<ReactionItems Item={reactionDrawerComponents.Item} />
|
||||
<ReactionItems
|
||||
Item={reactionDrawerComponents.Item}
|
||||
emojis={menuReactions}
|
||||
onMore={() => {
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
onSelect={selectReaction}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Choose an emoji to react with.
|
||||
</DrawerDescription>
|
||||
<div className="p-3">
|
||||
<EmojiPicker onSelect={selectReaction} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
@ -298,17 +658,38 @@ export default function MessageContextMenu({
|
|||
);
|
||||
}
|
||||
|
||||
const child = renderMessage((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={children} />
|
||||
<ContextMenuTrigger render={child} />
|
||||
<MessageMenuContent
|
||||
components={desktopMenuComponents}
|
||||
content={content}
|
||||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setPickerOpen(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
onSetEditing={onSetEditing}
|
||||
senderId={senderId}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker onSelect={selectReaction} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import { useMTP } from "@tensamin/mtp";
|
|||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import { createCache } from "@tensamin/cache";
|
||||
import { secureValueCodec } from "@tensamin/storage/secure";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
|
|
@ -61,7 +63,10 @@ function bytesFromProtocol(value: unknown): Uint8Array {
|
|||
|
||||
type EditableMessage = RawMessage & { failed?: boolean };
|
||||
type MessageEdit = Partial<
|
||||
Pick<EditableMessage, "Content" | "Edited" | "MessageState" | "failed">
|
||||
Pick<
|
||||
EditableMessage,
|
||||
"Content" | "Edited" | "MessageState" | "Reactions" | "failed"
|
||||
>
|
||||
>;
|
||||
|
||||
function updateMessagesBySendTime<T extends EditableMessage>(
|
||||
|
|
@ -155,6 +160,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||
const [ownId, setOwnId] = useState(0);
|
||||
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||
userId: number;
|
||||
value: Uint8Array | null;
|
||||
|
|
@ -183,6 +189,10 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
return currentChatSecretState.value;
|
||||
}, [currentChatSecretState, userIdValue]);
|
||||
|
||||
useEffect(() => {
|
||||
load("user_id").then(setOwnId);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userIdValue) return;
|
||||
|
||||
|
|
@ -325,6 +335,44 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
[load, send],
|
||||
);
|
||||
|
||||
const decryptMessages = useCallback(
|
||||
async (messages: RawMessages) => {
|
||||
if (!currentChatSecret) return [];
|
||||
return Promise.all(
|
||||
messages.map(async (message) => {
|
||||
try {
|
||||
return {
|
||||
...message,
|
||||
Content: await decryptChatText(
|
||||
currentChatSecret,
|
||||
message.Content,
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
log(1, "chat", "red", "Failed to decrypt historical message", err, {
|
||||
SendTime: message.SendTime,
|
||||
});
|
||||
return {
|
||||
...message,
|
||||
Content: "Failed to decrypt message",
|
||||
decryptionFailed: true,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
[currentChatSecret],
|
||||
);
|
||||
|
||||
const getCachedMessages = useCallback(async () => {
|
||||
if (!ownId || !userIdValue || !currentChatSecret) return [];
|
||||
const cache = createCache(String(ownId), {
|
||||
codec: secureValueCodec,
|
||||
});
|
||||
const window = await cache.conversations.get(userIdValue);
|
||||
return decryptMessages(window?.Messages ?? []);
|
||||
}, [currentChatSecret, decryptMessages, ownId, userIdValue]);
|
||||
|
||||
const getMessages = useCallback(
|
||||
async (amount: number, offset: number) => {
|
||||
if (!currentChatSecret) {
|
||||
|
|
@ -356,36 +404,22 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
sorted.map(async (message) => {
|
||||
try {
|
||||
return {
|
||||
...message,
|
||||
Content: await decryptChatText(
|
||||
currentChatSecret,
|
||||
message.Content,
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
log(1, "chat", "red", "Failed to decrypt historical message", err, {
|
||||
SendTime: message.SendTime,
|
||||
});
|
||||
return {
|
||||
...message,
|
||||
Content: "Failed to decrypt message",
|
||||
decryptionFailed: true,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return decryptMessages(sorted);
|
||||
},
|
||||
[currentChatSecret, send, userIdValue],
|
||||
[currentChatSecret, decryptMessages, send, userIdValue],
|
||||
);
|
||||
|
||||
const [ownId, setOwnId] = useState(0);
|
||||
useEffect(() => {
|
||||
load("user_id").then(setOwnId);
|
||||
}, [load]);
|
||||
if (!currentChatSecret || !ownId || !userIdValue) return;
|
||||
const queryKey = ["chat-messages", String(userIdValue), true] as const;
|
||||
void getCachedMessages().then((messages) => {
|
||||
if (messages.length === 0 || queryClient.getQueryData(queryKey)) return;
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(queryKey, {
|
||||
pages: [messages],
|
||||
pageParams: [0],
|
||||
});
|
||||
});
|
||||
}, [currentChatSecret, getCachedMessages, ownId, userIdValue]);
|
||||
|
||||
const editMessage = useCallback(
|
||||
(sendTime: number, edit: MessageEdit) => {
|
||||
|
|
@ -436,6 +470,155 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
[currentChatSecret, userIdValue],
|
||||
);
|
||||
|
||||
const removeMessage = useCallback(
|
||||
(sendTime: number) => {
|
||||
setLiveMessagesState((prev) =>
|
||||
prev.filter((message) => message.SendTime !== sendTime),
|
||||
);
|
||||
const queryKey = [
|
||||
"chat-messages",
|
||||
String(userIdValue),
|
||||
currentChatSecret !== null,
|
||||
] as const;
|
||||
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||
queryKey,
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
|
||||
const pages = current.pages.map((page) => {
|
||||
const nextPage = page.filter((message) => {
|
||||
const keep = message.SendTime !== sendTime;
|
||||
if (!keep) {
|
||||
updated = true;
|
||||
}
|
||||
return keep;
|
||||
});
|
||||
|
||||
return nextPage;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
pages,
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
[currentChatSecret, userIdValue],
|
||||
);
|
||||
|
||||
const deleteMessage = useCallback(
|
||||
async (sendTime: number) => {
|
||||
try {
|
||||
const response = await send("MessageDelete", {
|
||||
ChatPartnerId: userIdValue,
|
||||
SendTime: sendTime,
|
||||
});
|
||||
|
||||
assertProtocolSuccess("MessageDelete", response);
|
||||
removeMessage(sendTime);
|
||||
} catch (err) {
|
||||
log(1, "chat", "red", "Failed to delete message", err);
|
||||
toast("error", "Failed to delete message", String(err));
|
||||
}
|
||||
},
|
||||
[removeMessage, send, userIdValue],
|
||||
);
|
||||
|
||||
const setReaction = useCallback(
|
||||
async (sendTime: number, reaction: string, add: boolean) => {
|
||||
let previousReactions: RawMessage["Reactions"];
|
||||
let foundMessage = false;
|
||||
|
||||
const applyOptimisticUpdate = (message: EditableMessage) => {
|
||||
const current = message.Reactions ?? [];
|
||||
previousReactions = current;
|
||||
foundMessage = true;
|
||||
|
||||
return add
|
||||
? [...current, { Reaction: reaction, SenderId: ownId }]
|
||||
: current.filter(
|
||||
(item) => item.Reaction !== reaction || item.SenderId !== ownId,
|
||||
);
|
||||
};
|
||||
|
||||
setLiveMessagesState((current) =>
|
||||
current.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? { ...message, Reactions: applyOptimisticUpdate(message) }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
||||
const queryKey = [
|
||||
"chat-messages",
|
||||
String(userIdValue),
|
||||
currentChatSecret !== null,
|
||||
] as const;
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||
queryKey,
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
pages: current.pages.map((page) =>
|
||||
page.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? {
|
||||
...message,
|
||||
Reactions: applyOptimisticUpdate(message),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await send(
|
||||
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||
{
|
||||
ChatPartnerId: userIdValue,
|
||||
Reaction: reaction,
|
||||
SendTime: sendTime,
|
||||
},
|
||||
);
|
||||
assertProtocolSuccess(
|
||||
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||
response,
|
||||
);
|
||||
} catch (err) {
|
||||
if (foundMessage) {
|
||||
editMessage(sendTime, { Reactions: previousReactions });
|
||||
}
|
||||
log(1, "chat", "red", "Failed to update reaction", err);
|
||||
toast("error", "Failed to update reaction", String(err));
|
||||
}
|
||||
},
|
||||
[currentChatSecret, editMessage, ownId, send, userIdValue],
|
||||
);
|
||||
|
||||
const addReaction = useCallback(
|
||||
(sendTime: number, reaction: string) =>
|
||||
setReaction(sendTime, reaction, true),
|
||||
[setReaction],
|
||||
);
|
||||
const removeReaction = useCallback(
|
||||
(sendTime: number, reaction: string) =>
|
||||
setReaction(sendTime, reaction, false),
|
||||
[setReaction],
|
||||
);
|
||||
|
||||
const addLiveMessage = useCallback(
|
||||
(message: RawMessage) => {
|
||||
const localId =
|
||||
|
|
@ -519,6 +702,39 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MessageReactionLive") {
|
||||
const rawData = message.data as {
|
||||
ChatPartnerId: unknown;
|
||||
SendTime: unknown;
|
||||
};
|
||||
const chatPartnerId = Number(rawData.ChatPartnerId);
|
||||
const sendTime = Number(rawData.SendTime);
|
||||
|
||||
if (chatPartnerId !== userIdValue || !Number.isFinite(sendTime)) return;
|
||||
|
||||
void send("MessageGet", { SendTime: sendTime })
|
||||
.then((response) => {
|
||||
assertProtocolSuccess("MessageGet", response);
|
||||
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] });
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "chat", "red", "Failed to refresh message reactions", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MessageDeleteLive") {
|
||||
const data = message.data as {
|
||||
ChatPartnerId: number;
|
||||
SendTime: number;
|
||||
};
|
||||
|
||||
if (data.ChatPartnerId !== userIdValue) return;
|
||||
|
||||
removeMessage(data.SendTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== "MessageState") return;
|
||||
|
||||
const rawData = message.data as {
|
||||
|
|
@ -564,7 +780,14 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
MessageState: nextState.MessageState,
|
||||
});
|
||||
});
|
||||
}, [currentChatSecret, editMessage, subscribePush, userIdValue]);
|
||||
}, [
|
||||
currentChatSecret,
|
||||
editMessage,
|
||||
removeMessage,
|
||||
send,
|
||||
subscribePush,
|
||||
userIdValue,
|
||||
]);
|
||||
|
||||
// Replys
|
||||
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
||||
|
|
@ -578,6 +801,9 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
liveMessages: () => liveMessagesState,
|
||||
addLiveMessage,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
clearLiveMessages,
|
||||
chatSecret: currentChatSecret,
|
||||
userId: userIdValue,
|
||||
|
|
@ -602,6 +828,9 @@ type contextType = {
|
|||
setFailed: (failed: boolean) => void;
|
||||
};
|
||||
editMessage: (sendTime: number, edit: MessageEdit) => void;
|
||||
deleteMessage: (sendTime: number) => void;
|
||||
addReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||
clearLiveMessages: () => void;
|
||||
chatSecret: Uint8Array | null;
|
||||
userId: number;
|
||||
|
|
|
|||
|
|
@ -186,34 +186,14 @@ export default function Screen() {
|
|||
return [...liveMessageChunks, ...historicalMessageChunks];
|
||||
}, [historicalMessageChunks, liveMessageChunks]);
|
||||
|
||||
const shouldShowConversationStart =
|
||||
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
||||
const virtualRowCount =
|
||||
messageChunks.length + (shouldShowConversationStart ? 1 : 0);
|
||||
const virtualRowCount = messageChunks.length;
|
||||
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) => {
|
||||
if (shouldShowConversationStart && index === messageChunks.length) {
|
||||
return "conversation-start";
|
||||
}
|
||||
|
||||
return messageChunks[index]?.key ?? index;
|
||||
},
|
||||
[messageChunks, shouldShowConversationStart],
|
||||
(index: number) => messageChunks[index]?.key ?? index,
|
||||
[messageChunks],
|
||||
);
|
||||
|
||||
const estimateSize = React.useCallback(
|
||||
(index: number) => {
|
||||
const isConversationStart =
|
||||
shouldShowConversationStart && index === messageChunks.length;
|
||||
if (isConversationStart) {
|
||||
return FALLBACK_MESSAGE_HEIGHT;
|
||||
}
|
||||
|
||||
return FALLBACK_MESSAGE_HEIGHT;
|
||||
},
|
||||
[messageChunks, shouldShowConversationStart],
|
||||
);
|
||||
const estimateSize = React.useCallback(() => FALLBACK_MESSAGE_HEIGHT, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
|
|
@ -464,32 +444,6 @@ export default function Screen() {
|
|||
className="absolute bottom-0 left-0 h-px w-full"
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (
|
||||
shouldShowConversationStart &&
|
||||
virtualRow.index === messageChunks.length
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key="conversation-start"
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex justify-start scale-y-[-1]">
|
||||
<div className="text-sm text-foreground/55 px-2.5">
|
||||
Conversation start
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chunkIndex = virtualRow.index;
|
||||
const chunk = messageChunks[chunkIndex];
|
||||
if (!chunk) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
- Implement context menu features
|
||||
- Forward
|
||||
- Pin Message
|
||||
- Reply
|
||||
- Add default-emoji-hotkey
|
||||
- Placeholder image if media fails to load
|
||||
- Signature verifications via ed25519 key
|
||||
- Confirmation when exiting with text in the input box.
|
||||
- Add arrow up hotkey to edit last message
|
||||
- Add arrow up hotkey to edit last message (req: packages/hotkeys)
|
||||
- Drop any unique reactions above 10
|
||||
- Make the emoji picker not get moved with the mini context menu
|
||||
- Add proper loading skeleton
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
"./text": "./src/text.tsx",
|
||||
"./input": "./src/input.tsx"
|
||||
"./input": "./src/input.tsx",
|
||||
"./emoji": "./src/emoji.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
@ -13,10 +14,15 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@tensamin/ui": "*",
|
||||
"@twemoji/api": "^17.0.3",
|
||||
"emojibase-data": "^17.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
|
|
|
|||
138
packages/markdown/src/emoji.test.ts
Normal file
138
packages/markdown/src/emoji.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("@tensamin/ui", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => children,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => children,
|
||||
TooltipTrigger: ({ render }: { render: ReactNode }) => render,
|
||||
}));
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { normalizeShortcode, resolveEmoji, searchEmojis } from "./emojiData";
|
||||
import { parseEmojiText, parseInlineNodes } from "./markdown";
|
||||
import {
|
||||
createEmojiCompletionSource,
|
||||
findEmojiRanges,
|
||||
MAX_RENDERED_EMOJI_OPTIONS,
|
||||
} from "./input";
|
||||
|
||||
describe("emoji shortcodes", () => {
|
||||
it("normalizes aliases to their canonical shortcode", () => {
|
||||
expect(normalizeShortcode(":flame:")).toBe(":fire:");
|
||||
expect(normalizeShortcode("+1")).toBe(":thumbsup:");
|
||||
});
|
||||
|
||||
it("resolves every search result to a Twemoji hexcode", () => {
|
||||
const results = searchEmojis("fire");
|
||||
expect(results[0]?.shortcode).toBe(":fire:");
|
||||
expect(results.every((emoji) => emoji.hexcode.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows all emojis for an empty query", () => {
|
||||
expect(searchEmojis("").length).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("bounds the number of mounted autocomplete rows", () => {
|
||||
expect(MAX_RENDERED_EMOJI_OPTIONS).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("parses known shortcodes and preserves unknown ones", () => {
|
||||
expect(parseEmojiText("a :fire: b :not_an_emoji:")).toEqual([
|
||||
{ type: "text", value: "a " },
|
||||
{ type: "emoji", shortcode: ":fire:" },
|
||||
{ type: "text", value: " b :not_an_emoji:" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes a valid shortcode sharing an unknown closing colon", () => {
|
||||
expect(parseEmojiText(":bla:thumbsup:")).toEqual([
|
||||
{ type: "text", value: ":bla" },
|
||||
{ type: "emoji", shortcode: ":thumbsup:" },
|
||||
]);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: ":bla:thumbsup:",
|
||||
extensions: [markdown()],
|
||||
});
|
||||
expect(findEmojiRanges(state)[0]?.from).toBe(4);
|
||||
});
|
||||
|
||||
it("does not parse underscores inside emoji shortcodes as emphasis", () => {
|
||||
expect(parseInlineNodes("before :white_check_mark: after")).toEqual([
|
||||
{ type: "text", value: "before " },
|
||||
{ type: "emoji", shortcode: ":white_check_mark:" },
|
||||
{ type: "text", value: " after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("contains the picker defaults", () => {
|
||||
expect(resolveEmoji(":thumbsup:")).toBeDefined();
|
||||
expect(resolveEmoji(":white_check_mark:")).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds completed emoji shortcodes in editor state", () => {
|
||||
const state = EditorState.create({
|
||||
doc: "before :fire: after :not_an_emoji:",
|
||||
extensions: [markdown()],
|
||||
});
|
||||
|
||||
expect(
|
||||
findEmojiRanges(state).map(({ from, shortcode, to }) => ({
|
||||
from,
|
||||
shortcode,
|
||||
to,
|
||||
})),
|
||||
).toEqual([{ from: 7, shortcode: ":fire:", to: 13 }]);
|
||||
});
|
||||
|
||||
it("does not replace emoji shortcodes inside code", () => {
|
||||
const state = EditorState.create({
|
||||
doc: "`:fire:`\n\n```\n:fire:\n```\n\n:fire:",
|
||||
extensions: [markdown()],
|
||||
});
|
||||
|
||||
expect(findEmojiRanges(state)).toHaveLength(1);
|
||||
expect(findEmojiRanges(state)[0]?.from).toBe(26);
|
||||
});
|
||||
|
||||
it("ranks frequently used emojis first for a bare colon", async () => {
|
||||
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
|
||||
const result = await createEmojiCompletionSource({
|
||||
":fire:": 50,
|
||||
":thumbsup:": 2,
|
||||
})(new CompletionContext(state, 1, false));
|
||||
|
||||
expect(result?.options[0]?.displayLabel).toBe(":fire:");
|
||||
expect(result?.options[1]?.displayLabel).toBe(":thumbsup:");
|
||||
});
|
||||
|
||||
it("keeps typed relevance above usage frequency", async () => {
|
||||
const state = EditorState.create({
|
||||
doc: ":fire",
|
||||
extensions: [markdown()],
|
||||
});
|
||||
const result = await createEmojiCompletionSource({
|
||||
":fire_engine:": 10000,
|
||||
":fire:": 1,
|
||||
})(new CompletionContext(state, 5, false));
|
||||
|
||||
expect(result?.options[0]?.displayLabel).toBe(":fire:");
|
||||
expect(result?.options[0]?.boost).toBeGreaterThan(
|
||||
result?.options[1]?.boost ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("merges alias frequencies into canonical completions", async () => {
|
||||
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
|
||||
const result = await createEmojiCompletionSource({
|
||||
":fire:": 2,
|
||||
":flame:": 3,
|
||||
})(new CompletionContext(state, 1, false));
|
||||
const fire = result?.options.find(
|
||||
(option) => option.displayLabel === ":fire:",
|
||||
);
|
||||
|
||||
expect(fire?.boost).toBe(15);
|
||||
});
|
||||
});
|
||||
50
packages/markdown/src/emoji.tsx
Normal file
50
packages/markdown/src/emoji.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import twemoji from "@twemoji/api";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { resolveEmoji } from "./emojiData";
|
||||
|
||||
export {
|
||||
emojis,
|
||||
findEmojiShortcodes,
|
||||
normalizeShortcode,
|
||||
resolveEmoji,
|
||||
searchEmojis,
|
||||
} from "./emojiData";
|
||||
export type { EmojiDefinition } from "./emojiData";
|
||||
|
||||
export function getEmojiUrl(shortcode: string): string | undefined {
|
||||
const emoji = resolveEmoji(shortcode);
|
||||
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
|
||||
}
|
||||
|
||||
export default function Emoji({
|
||||
className = "h-6 w-6",
|
||||
shortcode,
|
||||
tooltip = true,
|
||||
}: {
|
||||
className?: string;
|
||||
shortcode: string;
|
||||
tooltip?: boolean;
|
||||
}) {
|
||||
const emoji = resolveEmoji(shortcode);
|
||||
if (!emoji) return <span>{shortcode}</span>;
|
||||
|
||||
const image = (
|
||||
<img
|
||||
alt={emoji.shortcode}
|
||||
className={className}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) return image;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={image} />
|
||||
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
96
packages/markdown/src/emojiData.ts
Normal file
96
packages/markdown/src/emojiData.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
|
||||
|
||||
type ShortcodeValue = string | string[];
|
||||
|
||||
export type EmojiDefinition = {
|
||||
aliases: readonly string[];
|
||||
hexcode: string;
|
||||
name: string;
|
||||
shortcode: string;
|
||||
};
|
||||
|
||||
function normalizeName(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^:+|:+$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
||||
shortcodeData as Record<string, ShortcodeValue>,
|
||||
).map(([hexcode, value]) => {
|
||||
const aliases = Array.isArray(value) ? value : [value];
|
||||
const name = aliases[0];
|
||||
|
||||
return {
|
||||
aliases,
|
||||
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
|
||||
name,
|
||||
shortcode: `:${name}:`,
|
||||
};
|
||||
});
|
||||
|
||||
const emojiByName = new Map<string, EmojiDefinition>();
|
||||
for (const emoji of emojis) {
|
||||
for (const alias of emoji.aliases) {
|
||||
emojiByName.set(normalizeName(alias), emoji);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEmoji(value: string): EmojiDefinition | undefined {
|
||||
return emojiByName.get(normalizeName(value));
|
||||
}
|
||||
|
||||
export function normalizeShortcode(value: string): string | undefined {
|
||||
return resolveEmoji(value)?.shortcode;
|
||||
}
|
||||
|
||||
export function findEmojiShortcodes(value: string) {
|
||||
const matches: Array<{
|
||||
emoji: EmojiDefinition;
|
||||
from: number;
|
||||
to: number;
|
||||
}> = [];
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < value.length) {
|
||||
const from = value.indexOf(":", searchFrom);
|
||||
if (from === -1) break;
|
||||
|
||||
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
|
||||
if (!candidate) {
|
||||
searchFrom = from + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const emoji = resolveEmoji(candidate[1]);
|
||||
if (!emoji) {
|
||||
// The closing colon may also open the next valid shortcode.
|
||||
searchFrom = from + candidate[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const to = from + candidate[0].length;
|
||||
matches.push({ emoji, from, to });
|
||||
searchFrom = to;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function searchEmojis(query: string): EmojiDefinition[] {
|
||||
const normalizedQuery = normalizeName(query);
|
||||
if (!normalizedQuery) return [...emojis];
|
||||
|
||||
return emojis
|
||||
.map((emoji) => {
|
||||
const names = emoji.aliases.map(normalizeName);
|
||||
const exact = names.includes(normalizedQuery);
|
||||
const prefix = names.some((name) => name.startsWith(normalizedQuery));
|
||||
const contains = names.some((name) => name.includes(normalizedQuery));
|
||||
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
|
||||
})
|
||||
.filter(({ rank }) => rank < 3)
|
||||
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
|
||||
.map(({ emoji }) => emoji);
|
||||
}
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import {
|
||||
acceptCompletion,
|
||||
autocompletion,
|
||||
completionStatus,
|
||||
pickedCompletion,
|
||||
startCompletion,
|
||||
type Completion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import {
|
||||
EditorState,
|
||||
EditorSelection,
|
||||
Annotation,
|
||||
Compartment,
|
||||
Prec,
|
||||
Transaction,
|
||||
type Extension,
|
||||
type Range,
|
||||
type SelectionRange,
|
||||
|
|
@ -12,6 +27,7 @@ import {
|
|||
keymap,
|
||||
placeholder,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
type DecorationSet,
|
||||
type KeyBinding,
|
||||
type ViewUpdate,
|
||||
|
|
@ -24,8 +40,17 @@ import {
|
|||
} from "@codemirror/commands";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||
import Emoji, {
|
||||
findEmojiShortcodes,
|
||||
getEmojiUrl,
|
||||
resolveEmoji,
|
||||
searchEmojis,
|
||||
} from "./emoji";
|
||||
|
||||
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||
|
||||
export type InputProps = {
|
||||
ref?: HTMLDivElement;
|
||||
|
|
@ -39,6 +64,8 @@ export type InputProps = {
|
|||
paddingX?: CSSProperties["padding"];
|
||||
paddingY?: CSSProperties["padding"];
|
||||
className?: string;
|
||||
emojiFrequencies?: Readonly<Record<string, number>>;
|
||||
onEmojiSelect?: (shortcode: string) => void;
|
||||
};
|
||||
|
||||
type InputStyle = CSSProperties & {
|
||||
|
|
@ -76,6 +103,180 @@ const delDecoration = Decoration.mark({ class: "tm-md-del" });
|
|||
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
||||
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
||||
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
||||
const externalValueSync = Annotation.define<boolean>();
|
||||
const widgetRoots = new WeakMap<HTMLElement, Root>();
|
||||
|
||||
type EmojiRange = {
|
||||
from: number;
|
||||
shortcode: string;
|
||||
to: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
class EmojiWidget extends WidgetType {
|
||||
readonly shortcode: string;
|
||||
readonly url: string;
|
||||
|
||||
constructor(shortcode: string, url: string) {
|
||||
super();
|
||||
this.shortcode = shortcode;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
eq(other: EmojiWidget) {
|
||||
return other.shortcode === this.shortcode && other.url === this.url;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const container = document.createElement("span");
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
|
||||
);
|
||||
widgetRoots.set(container, root);
|
||||
return container;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
widgetRoots.get(dom)?.unmount();
|
||||
widgetRoots.delete(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function codeRanges(state: EditorState) {
|
||||
const ranges: Array<{ from: number; to: number }> = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter(node) {
|
||||
if (
|
||||
node.name === "InlineCode" ||
|
||||
node.name === "FencedCode" ||
|
||||
node.name === "CodeBlock"
|
||||
) {
|
||||
ranges.push({ from: node.from, to: node.to });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function findEmojiRanges(state: EditorState): EmojiRange[] {
|
||||
const document = state.doc.toString();
|
||||
const excluded = codeRanges(state);
|
||||
const ranges: EmojiRange[] = [];
|
||||
|
||||
for (const match of findEmojiShortcodes(document)) {
|
||||
const { from, to } = match;
|
||||
const inCode = excluded.some((range) => from < range.to && to > range.from);
|
||||
const emoji = inCode ? undefined : match.emoji;
|
||||
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
|
||||
|
||||
if (emoji && url) {
|
||||
ranges.push({ from, shortcode: emoji.shortcode, to, url });
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
class EmojiPluginValue {
|
||||
decorations: DecorationSet;
|
||||
ranges: EmojiRange[];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.ranges = findEmojiRanges(view.state);
|
||||
this.decorations = this.buildDecorations();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
) {
|
||||
this.ranges = findEmojiRanges(update.state);
|
||||
this.decorations = this.buildDecorations();
|
||||
}
|
||||
}
|
||||
|
||||
private buildDecorations() {
|
||||
return Decoration.set(
|
||||
this.ranges.map((range) =>
|
||||
Decoration.replace({
|
||||
inclusive: false,
|
||||
widget: new EmojiWidget(range.shortcode, range.url),
|
||||
}).range(range.from, range.to),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
|
||||
decorations: (instance) => instance.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of(
|
||||
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||
),
|
||||
});
|
||||
|
||||
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
|
||||
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
|
||||
const deletions: Array<{ from: number; to: number }> = [];
|
||||
|
||||
for (const selection of view.state.selection.ranges) {
|
||||
if (selection.empty) {
|
||||
const emoji = ranges.find((range) =>
|
||||
direction === "backward"
|
||||
? selection.from > range.from && selection.from <= range.to
|
||||
: selection.from >= range.from && selection.from < range.to,
|
||||
);
|
||||
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
|
||||
continue;
|
||||
}
|
||||
|
||||
let from = selection.from;
|
||||
let to = selection.to;
|
||||
let changed = false;
|
||||
|
||||
for (const emoji of ranges) {
|
||||
if (from < emoji.to && to > emoji.from) {
|
||||
from = Math.min(from, emoji.from);
|
||||
to = Math.max(to, emoji.to);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) deletions.push({ from, to });
|
||||
}
|
||||
|
||||
if (deletions.length === 0) return false;
|
||||
|
||||
const merged = deletions
|
||||
.sort((a, b) => a.from - b.from)
|
||||
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
|
||||
const previous = result.at(-1);
|
||||
if (previous && deletion.from <= previous.to) {
|
||||
previous.to = Math.max(previous.to, deletion.to);
|
||||
} else {
|
||||
result.push({ ...deletion });
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
view.dispatch({
|
||||
changes: merged.map((range) => ({ from: range.from, to: range.to })),
|
||||
selection: EditorSelection.cursor(merged[0].from),
|
||||
scrollIntoView: true,
|
||||
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds markdown styling decorations every time the document or cursor selection changes.
|
||||
|
|
@ -115,14 +316,20 @@ export default function Input(props: InputProps) {
|
|||
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||
const ignoreSyncRef = useRef(false);
|
||||
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
||||
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
||||
props.onEmojiSelect,
|
||||
);
|
||||
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
|
||||
const completionCompartmentRef = useRef<Compartment | null>(null);
|
||||
completionCompartmentRef.current ??= new Compartment();
|
||||
const completionCompartment = completionCompartmentRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = props.onSubmit;
|
||||
onEmojiSelectRef.current = props.onEmojiSelect;
|
||||
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
||||
}, [props.onSubmit, props.invertEnterBehavior]);
|
||||
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elementRef.current) return;
|
||||
|
|
@ -131,12 +338,14 @@ export default function Input(props: InputProps) {
|
|||
doc: props.value,
|
||||
extensions: createEditorExtensions(
|
||||
(value) => {
|
||||
ignoreSyncRef.current = true;
|
||||
props.setValue(value);
|
||||
},
|
||||
() => props.placeholder,
|
||||
() => invertEnterBehaviorRef.current,
|
||||
() => onSubmitRef.current?.(),
|
||||
completionCompartment,
|
||||
props.emojiFrequencies,
|
||||
(shortcode) => onEmojiSelectRef.current?.(shortcode),
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -160,11 +369,6 @@ export default function Input(props: InputProps) {
|
|||
const next = props.value;
|
||||
const current = editor.state.doc.toString();
|
||||
|
||||
if (ignoreSyncRef.current) {
|
||||
ignoreSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === current) return;
|
||||
|
||||
editor.dispatch({
|
||||
|
|
@ -173,9 +377,30 @@ export default function Input(props: InputProps) {
|
|||
to: current.length,
|
||||
insert: next,
|
||||
},
|
||||
annotations: [
|
||||
externalValueSync.of(true),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
filter: false,
|
||||
});
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = viewRef.current;
|
||||
const compartment = completionCompartmentRef.current;
|
||||
if (!editor || !compartment) return;
|
||||
|
||||
const wasActive = completionStatus(editor.state) === "active";
|
||||
editor.dispatch({
|
||||
effects: compartment.reconfigure(
|
||||
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
|
||||
onEmojiSelectRef.current?.(shortcode),
|
||||
),
|
||||
),
|
||||
});
|
||||
if (wasActive) startCompletion(editor);
|
||||
}, [props.emojiFrequencies]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
|
|
@ -207,6 +432,9 @@ function createEditorExtensions(
|
|||
getPlaceholder: () => string | undefined,
|
||||
getInvertEnterBehavior: () => boolean,
|
||||
onSubmit: () => void,
|
||||
completionCompartment: Compartment,
|
||||
emojiFrequencies: Readonly<Record<string, number>> | undefined,
|
||||
onEmojiSelect: (shortcode: string) => void,
|
||||
): Extension[] {
|
||||
const editorKeymap = [
|
||||
...defaultKeymap,
|
||||
|
|
@ -217,7 +445,10 @@ function createEditorExtensions(
|
|||
const customEnterKeymap = keymap.of([
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: () => {
|
||||
run: (view) => {
|
||||
if (completionStatus(view.state) === "active") {
|
||||
return acceptCompletion(view);
|
||||
}
|
||||
if (!getInvertEnterBehavior()) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -228,7 +459,10 @@ function createEditorExtensions(
|
|||
},
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
run: (view) => {
|
||||
if (completionStatus(view.state) === "active") {
|
||||
return acceptCompletion(view);
|
||||
}
|
||||
if (getInvertEnterBehavior()) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -238,16 +472,48 @@ function createEditorExtensions(
|
|||
},
|
||||
},
|
||||
]);
|
||||
const completionTabKeymap = keymap.of([
|
||||
{
|
||||
key: "Tab",
|
||||
run: (view) =>
|
||||
completionStatus(view.state) === "active"
|
||||
? acceptCompletion(view)
|
||||
: false,
|
||||
},
|
||||
]);
|
||||
const emojiDeletionKeymap = keymap.of([
|
||||
{
|
||||
key: "Backspace",
|
||||
run: (view) => deleteEmoji(view, "backward"),
|
||||
},
|
||||
{
|
||||
key: "Delete",
|
||||
run: (view) => deleteEmoji(view, "forward"),
|
||||
},
|
||||
]);
|
||||
|
||||
return [
|
||||
history(),
|
||||
markdown(),
|
||||
completionCompartment.of(
|
||||
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
|
||||
),
|
||||
emojiDecorations,
|
||||
keymap.of(editorKeymap),
|
||||
Prec.highest(completionTabKeymap),
|
||||
Prec.highest(emojiDeletionKeymap),
|
||||
Prec.highest(customEnterKeymap),
|
||||
EditorView.lineWrapping,
|
||||
placeholder(getPlaceholder() ?? ""),
|
||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (!update.docChanged) return;
|
||||
if (
|
||||
update.transactions.some(
|
||||
(transaction) => transaction.annotation(externalValueSync) === true,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onChange(update.state.doc.toString());
|
||||
}),
|
||||
EditorView.theme({
|
||||
|
|
@ -267,6 +533,126 @@ function createEditorExtensions(
|
|||
];
|
||||
}
|
||||
|
||||
function createEmojiAutocomplete(
|
||||
frequencies: Readonly<Record<string, number>> | undefined,
|
||||
onEmojiSelect: (shortcode: string) => void,
|
||||
) {
|
||||
return autocompletion({
|
||||
activateOnTyping: true,
|
||||
addToOptions: [
|
||||
{
|
||||
position: 20,
|
||||
render(completion) {
|
||||
const container = document.createElement("span");
|
||||
createRoot(container).render(
|
||||
<Emoji
|
||||
className="tm-md-completion-emoji"
|
||||
shortcode={completion.label}
|
||||
/>,
|
||||
);
|
||||
return container;
|
||||
},
|
||||
},
|
||||
],
|
||||
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
|
||||
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedFrequencies(
|
||||
frequencies: Readonly<Record<string, number>> | undefined,
|
||||
) {
|
||||
const normalized = new Map<string, number>();
|
||||
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
|
||||
const shortcode = resolveEmoji(value)?.shortcode;
|
||||
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
|
||||
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createEmojiCompletionSource(
|
||||
frequencies?: Readonly<Record<string, number>>,
|
||||
onEmojiSelect: (shortcode: string) => void = () => undefined,
|
||||
) {
|
||||
const normalized = normalizedFrequencies(frequencies);
|
||||
const maxFrequency = Math.max(0, ...normalized.values());
|
||||
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
|
||||
if (!token) return null;
|
||||
if (
|
||||
codeRanges(context.state).some(
|
||||
(range) => token.from < range.to && token.to > range.from,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const characterBefore = context.state.sliceDoc(
|
||||
Math.max(0, token.from - 1),
|
||||
token.from,
|
||||
);
|
||||
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
|
||||
|
||||
const query = token.text.slice(1).toLowerCase();
|
||||
const options: Completion[] = searchEmojis(query)
|
||||
.map((emoji) => {
|
||||
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
|
||||
const matchedAlias =
|
||||
aliases.find((alias) => alias === query) ??
|
||||
aliases.find((alias) => alias.startsWith(query)) ??
|
||||
aliases.find((alias) => alias.includes(query)) ??
|
||||
emoji.name;
|
||||
const relevance = !query
|
||||
? 0
|
||||
: matchedAlias === query
|
||||
? 80
|
||||
: matchedAlias.startsWith(query)
|
||||
? 40
|
||||
: 0;
|
||||
const frequency = normalized.get(emoji.shortcode) ?? 0;
|
||||
const usage =
|
||||
maxFrequency > 0
|
||||
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
apply(view, completion, from, to) {
|
||||
view.dispatch({
|
||||
annotations: pickedCompletion.of(completion),
|
||||
changes: { from, insert: `${emoji.shortcode} `, to },
|
||||
selection: EditorSelection.cursor(
|
||||
from + emoji.shortcode.length + 1,
|
||||
),
|
||||
});
|
||||
onEmojiSelect(emoji.shortcode);
|
||||
},
|
||||
boost: relevance + usage,
|
||||
displayLabel: emoji.shortcode,
|
||||
label: `:${matchedAlias}:`,
|
||||
type: "text",
|
||||
frequency,
|
||||
relevance,
|
||||
} satisfies Completion & { frequency: number; relevance: number };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.relevance - a.relevance ||
|
||||
b.frequency - a.frequency ||
|
||||
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
|
||||
);
|
||||
|
||||
return {
|
||||
from: token.from,
|
||||
options,
|
||||
validFor: /^:[a-z0-9_+-]*$/i,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const emojiCompletionSource = createEmojiCompletionSource();
|
||||
|
||||
/**
|
||||
* Executes buildDecorations.
|
||||
* @param view Parameter view.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import * as React from "react";
|
||||
import Emoji from "./emoji";
|
||||
import { findEmojiShortcodes } from "./emojiData";
|
||||
|
||||
type InlineNode =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "emoji"; shortcode: string }
|
||||
| { type: "strong"; value: string }
|
||||
| { type: "em"; value: string }
|
||||
| { type: "del"; value: string }
|
||||
|
|
@ -73,14 +76,14 @@ type MarkdownBlock =
|
|||
| TableBlock;
|
||||
|
||||
const INLINE_TOKEN_REGEX =
|
||||
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
|
||||
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
||||
|
||||
/**
|
||||
* Executes parseInlineNodes.
|
||||
* @param input Parameter input.
|
||||
* @returns InlineNode[].
|
||||
*/
|
||||
function parseInlineNodes(input: string): InlineNode[] {
|
||||
export function parseInlineNodes(input: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
|
||||
let cursor = 0;
|
||||
|
|
@ -91,7 +94,7 @@ function parseInlineNodes(input: string): InlineNode[] {
|
|||
const raw = match[0];
|
||||
|
||||
if (index > cursor) {
|
||||
nodes.push({ type: "text", value: input.slice(cursor, index) });
|
||||
nodes.push(...parseEmojiText(input.slice(cursor, index)));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined && match[2] !== undefined) {
|
||||
|
|
@ -111,7 +114,7 @@ function parseInlineNodes(input: string): InlineNode[] {
|
|||
} else if (match[9] !== undefined || match[10] !== undefined) {
|
||||
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
|
||||
} else {
|
||||
nodes.push({ type: "text", value: raw });
|
||||
nodes.push(...parseEmojiText(raw));
|
||||
}
|
||||
|
||||
cursor = index + raw.length;
|
||||
|
|
@ -119,13 +122,33 @@ function parseInlineNodes(input: string): InlineNode[] {
|
|||
}
|
||||
|
||||
if (cursor < input.length) {
|
||||
nodes.push({ type: "text", value: input.slice(cursor) });
|
||||
nodes.push(...parseEmojiText(input.slice(cursor)));
|
||||
}
|
||||
|
||||
INLINE_TOKEN_REGEX.lastIndex = 0;
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function parseEmojiText(input: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of findEmojiShortcodes(input)) {
|
||||
if (match.from > cursor) {
|
||||
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
|
||||
}
|
||||
|
||||
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
|
||||
cursor = match.to;
|
||||
}
|
||||
|
||||
if (cursor < input.length) {
|
||||
nodes.push({ type: "text", value: input.slice(cursor) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes collectInlineRanges.
|
||||
* @param input Parameter input.
|
||||
|
|
@ -375,10 +398,16 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
|||
return node.value;
|
||||
}
|
||||
|
||||
if (node.type === "emoji") {
|
||||
return (
|
||||
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "strong") {
|
||||
return (
|
||||
<strong key={index} className="tm-md-strong">
|
||||
{node.value}
|
||||
{renderInline(parseEmojiText(node.value))}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
|
@ -386,7 +415,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
|||
if (node.type === "em") {
|
||||
return (
|
||||
<em key={index} className="tm-md-em">
|
||||
{node.value}
|
||||
{renderInline(parseEmojiText(node.value))}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
|
@ -394,7 +423,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
|||
if (node.type === "del") {
|
||||
return (
|
||||
<del key={index} className="tm-md-del">
|
||||
{node.value}
|
||||
{renderInline(parseEmojiText(node.value))}
|
||||
</del>
|
||||
);
|
||||
}
|
||||
|
|
@ -416,7 +445,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{node.label}
|
||||
{renderInline(parseEmojiText(node.label))}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -637,7 +666,7 @@ function readTable(
|
|||
}
|
||||
|
||||
const markdownStyles = `
|
||||
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.55; font-size: 0.95rem; }
|
||||
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; }
|
||||
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
||||
.tm-md-h1 { font-size: 1.65rem; }
|
||||
.tm-md-h2 { font-size: 1.45rem; }
|
||||
|
|
@ -655,6 +684,7 @@ const markdownStyles = `
|
|||
.tm-md-del { text-decoration: line-through; }
|
||||
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
|
||||
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
||||
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
|
||||
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
||||
.tm-md-li { margin: 0.2rem 0; }
|
||||
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
|
||||
|
|
@ -670,8 +700,21 @@ const markdownStyles = `
|
|||
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
||||
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
||||
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
||||
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
||||
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
|
||||
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export default function Text(props: TextProps) {
|
|||
() => parseMarkdownBlocks(props.value),
|
||||
[props.value],
|
||||
);
|
||||
const renderedBlocks = React.useMemo(() => renderBlocks(blocks), [blocks]);
|
||||
|
||||
return <div className="tm-md-root">{renderBlocks(blocks)}</div>;
|
||||
return <div className="tm-md-root">{renderedBlocks}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
|||
|
||||
export type PushHandler = (message: ProtocolMessage) => void;
|
||||
|
||||
export type MTPExchange = {
|
||||
type: keyof Schemas & string;
|
||||
data: unknown;
|
||||
response: ProtocolMessage;
|
||||
};
|
||||
|
||||
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
|
||||
|
||||
type ContextType = {
|
||||
send: BoundSendFn;
|
||||
subscribe: <T extends keyof Schemas & string>(
|
||||
|
|
@ -67,6 +75,7 @@ type ContextType = {
|
|||
handler: (message: ProtocolMessage<T>) => void,
|
||||
) => () => void;
|
||||
subscribePush: (handler: PushHandler) => () => void;
|
||||
addInterceptor: (interceptor: MTPInterceptor) => () => void;
|
||||
readyState: number;
|
||||
ownPing: number;
|
||||
iotaPing: number;
|
||||
|
|
@ -153,6 +162,7 @@ export function Provider(props: {
|
|||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||
null,
|
||||
);
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
|
||||
|
|
@ -201,6 +211,8 @@ export function Provider(props: {
|
|||
const unsubscribers = [
|
||||
"MessageLive",
|
||||
"MessageEditLive",
|
||||
"MessageReactionLive",
|
||||
"MessageDeleteLive",
|
||||
"MessageState",
|
||||
"CallInvite",
|
||||
"ErrorNoIota",
|
||||
|
|
@ -215,6 +227,11 @@ export function Provider(props: {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||
interceptorsRef.current.add(interceptor);
|
||||
return () => interceptorsRef.current.delete(interceptor);
|
||||
}, []);
|
||||
|
||||
// Custom Pings
|
||||
useEffect(() => {
|
||||
if (!connected || !identified) {
|
||||
|
|
@ -283,7 +300,13 @@ export function Provider(props: {
|
|||
|
||||
await MTPClient.init();
|
||||
|
||||
const userId = await load("user_id");
|
||||
const [userId, keyring] = await Promise.all([
|
||||
load("user_id"),
|
||||
load("mtp_keyring"),
|
||||
]);
|
||||
if (!userId || !keyring) {
|
||||
throw new Error("Missing login credentials");
|
||||
}
|
||||
const forcedOmikronUrl = await load("forced_omikron_url");
|
||||
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
||||
|
||||
|
|
@ -337,7 +360,7 @@ export function Provider(props: {
|
|||
url,
|
||||
credentials: {
|
||||
clientId: userId,
|
||||
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
||||
keyring: base64ToUint8Array(keyring),
|
||||
},
|
||||
hostPublicKey: omikronPublicKey,
|
||||
descriptor: "client",
|
||||
|
|
@ -395,6 +418,10 @@ export function Provider(props: {
|
|||
(message) => {
|
||||
try {
|
||||
unsubscribe();
|
||||
if (message.type.startsWith("Error")) {
|
||||
reject(new Error(`Authentication failed: ${message.type}`));
|
||||
return;
|
||||
}
|
||||
resolve(validateResponse("IdentificationResponse", message));
|
||||
} catch (authPayloadError) {
|
||||
unsubscribe();
|
||||
|
|
@ -562,7 +589,15 @@ export function Provider(props: {
|
|||
const sendQueued: BoundSendFn = useMemo(
|
||||
() => async (type, data, options) => {
|
||||
const mtp = await mtpRef.get();
|
||||
return mtp.send(type, data, options);
|
||||
const response = await mtp.send(type, data, options);
|
||||
for (const interceptor of interceptorsRef.current) {
|
||||
void Promise.resolve(
|
||||
interceptor({ type, data, response: response as ProtocolMessage }),
|
||||
).catch((error) => {
|
||||
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
[mtpRef],
|
||||
);
|
||||
|
|
@ -573,6 +608,7 @@ export function Provider(props: {
|
|||
send: sendQueued,
|
||||
subscribe,
|
||||
subscribePush,
|
||||
addInterceptor,
|
||||
readyState,
|
||||
ownPing,
|
||||
iotaPing,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,8 @@
|
|||
export { Provider, useMTP } from "./context";
|
||||
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
|
||||
export type {
|
||||
BoundSendFn,
|
||||
MTPExchange,
|
||||
MTPInterceptor,
|
||||
PushHandler,
|
||||
ProtocolMessage,
|
||||
} from "./context";
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
- Get omikron url & public key from omega
|
||||
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> =
|
||||
Storage[K] extends Array<infer Item> ? Item : never;
|
||||
|
||||
export function Switch({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
export function Switch({ label, id }: {
|
||||
label: React.ReactNode;
|
||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||
}) {
|
||||
|
|
@ -42,23 +39,16 @@ export function Switch({
|
|||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<UISwitch
|
||||
id={id}
|
||||
checked={value}
|
||||
onCheckedChange={(value) => {
|
||||
setValue(value);
|
||||
save(id, value);
|
||||
}}
|
||||
/>
|
||||
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
save(id, nextValue);
|
||||
}} />
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function List<K extends ListStorageKey>({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
export function List<K extends ListStorageKey>({ label, id }: {
|
||||
label: React.ReactNode;
|
||||
id: K;
|
||||
}) {
|
||||
|
|
@ -78,30 +68,20 @@ export function List<K extends ListStorageKey>({
|
|||
|
||||
const toStorageItem = (value: string): ListStorageItem<K> => {
|
||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
||||
|
||||
if (typeof referenceItem === "number") {
|
||||
return Number(value) as ListStorageItem<K>;
|
||||
}
|
||||
|
||||
return value as ListStorageItem<K>;
|
||||
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
|
||||
const nextItem = toStorageItem(trimmedValue);
|
||||
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
||||
|
||||
persistItems([...items, nextItem] as Storage[K]);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const deleteItems = (indexes: Set<number>) => {
|
||||
const nextItems = items.filter(
|
||||
(_, index) => !indexes.has(index),
|
||||
) as Storage[K];
|
||||
|
||||
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K];
|
||||
setItems(nextItems);
|
||||
setSelectedItems(new Set());
|
||||
save(id, nextItems);
|
||||
|
|
@ -112,13 +92,9 @@ export function List<K extends ListStorageKey>({
|
|||
<Label>{label}</Label>
|
||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addItem();
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
<Button onClick={addItem}>Add item</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
|
|
@ -126,45 +102,19 @@ export function List<K extends ListStorageKey>({
|
|||
const labelId = `${String(id)}-${index}`;
|
||||
const selected = selectedItems.has(index);
|
||||
const deletingSelectedItems = selectedItems.size > 1;
|
||||
|
||||
return (
|
||||
<ContextMenu key={`${String(item)}-${index}`}>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
||||
<Checkbox
|
||||
id={labelId}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedItems((previous) => {
|
||||
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
||||
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => {
|
||||
const nextSelected = new Set(previous);
|
||||
|
||||
if (checked) {
|
||||
nextSelected.add(index);
|
||||
} else {
|
||||
nextSelected.delete(index);
|
||||
}
|
||||
|
||||
if (checked) nextSelected.add(index);
|
||||
else nextSelected.delete(index);
|
||||
return nextSelected;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={labelId}>{String(item)}</Label>
|
||||
<div />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
})} />
|
||||
<Label htmlFor={labelId}>{String(item)}</Label><div />
|
||||
</div>} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteItems(
|
||||
deletingSelectedItems
|
||||
? selectedItems
|
||||
: new Set([index]),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}>
|
||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
||||
</ContextMenuItem>
|
||||
</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 { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui";
|
||||
|
||||
export default function Screen() {
|
||||
import { settingsNavigation } from "./manifest";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{!isMobile && <SettingsSidebar />}
|
||||
|
||||
{/* Page */}
|
||||
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{location.pathname
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())}
|
||||
</h1>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
|
@ -28,44 +25,31 @@ export default function Screen() {
|
|||
}
|
||||
|
||||
export function SettingsSidebar() {
|
||||
const settingsOptions = options as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "w-full p-1"
|
||||
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||
isMobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||
"flex flex-col gap-6",
|
||||
)}
|
||||
>
|
||||
{/* Settings */}
|
||||
{Object.keys(settingsOptions).map((category) => (
|
||||
// Category
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
||||
{Object.keys(settingsOptions[category]).map((page) => (
|
||||
// Page
|
||||
<div key={page}>
|
||||
{settingsNavigation
|
||||
.filter((page) => page.category === category)
|
||||
.map((page) => (
|
||||
<Button
|
||||
key={page.path}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/settings/" + page.toLowerCase(),
|
||||
})
|
||||
}
|
||||
onClick={() => navigate({ to: `/settings/${page.path}` })}
|
||||
>
|
||||
{(page as string).charAt(0).toUpperCase() +
|
||||
(page as string).slice(1)}
|
||||
{page.label}
|
||||
</Button>
|
||||
</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,7 +1,10 @@
|
|||
import { List, Switch } from "@/features/settings/components";
|
||||
import { Kbd } from "@tensamin/ui";
|
||||
import { storageDefaults } from "@tensamin/shared/data";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Button, Kbd } from "@tensamin/ui";
|
||||
import { List, Switch } from "../components";
|
||||
|
||||
export default function Page() {
|
||||
const { save } = useStorage();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Switch
|
||||
|
|
@ -29,6 +32,14 @@ export default function Page() {
|
|||
Trusted embed domains can get your IP-Address! Only add domains if you
|
||||
really trust them!
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void save("reactions", storageDefaults.reactions)}
|
||||
>
|
||||
Reset Emoji Ranks
|
||||
</Button>
|
||||
</div>
|
||||
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { SettingsSidebar } from "@/features/settings/layout";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
import { SettingsSidebar } from "../layout";
|
||||
|
||||
export default function Page() {
|
||||
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",
|
||||
"./desktopMedia": "./src/desktopMedia.tsx",
|
||||
"./log": "./src/log.tsx",
|
||||
"./indexedDb": "./src/indexedDb.ts",
|
||||
"./settings": "./src/settings.ts",
|
||||
"./features/legal/schema": "./src/features/legal/schema.ts",
|
||||
"./features/conversation/schema": "./src/features/conversation/schema.ts"
|
||||
|
|
|
|||
|
|
@ -202,6 +202,20 @@ export const mtp = {
|
|||
PingIota: z.number(),
|
||||
}),
|
||||
},
|
||||
MessageDelete: {
|
||||
request: z.object({
|
||||
ChatPartnerId: z.number(),
|
||||
SendTime: z.number(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
MessageDeleteLive: {
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
ChatPartnerId: z.number(),
|
||||
SendTime: z.number(),
|
||||
}),
|
||||
},
|
||||
MessageEditLive: {
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
|
|
@ -416,6 +430,8 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
legal_docs: z.infer<typeof legalDocsSchema>;
|
||||
cached_contacts: Contacts;
|
||||
cached_communities: Communities;
|
||||
cache_contacts: number;
|
||||
cache_messages_per_chat: number;
|
||||
omega_url: string;
|
||||
forced_omikron_url: string | undefined;
|
||||
forced_omikron_public_key: string | undefined;
|
||||
|
|
@ -435,6 +451,7 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
reactions: Record<string, number>;
|
||||
}
|
||||
|
||||
export const storageDefaults: Storage = {
|
||||
|
|
@ -467,6 +484,8 @@ export const storageDefaults: Storage = {
|
|||
},
|
||||
cached_contacts: [],
|
||||
cached_communities: [],
|
||||
cache_contacts: 5,
|
||||
cache_messages_per_chat: 20,
|
||||
omega_url: "https://omega.tensamin.net",
|
||||
forced_omikron_url: undefined,
|
||||
forced_omikron_public_key: undefined,
|
||||
|
|
@ -506,6 +525,11 @@ export const storageDefaults: Storage = {
|
|||
chat_picker_saved_media: [],
|
||||
chat_picker_last_tab: "gif",
|
||||
chat_picker_size: null,
|
||||
reactions: {
|
||||
":thumbsup:": 3,
|
||||
":fire:": 2,
|
||||
":white_check_mark:": 1,
|
||||
},
|
||||
};
|
||||
|
||||
// User Status
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ type ElectronDesktopApi = {
|
|||
>;
|
||||
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
||||
};
|
||||
call?: {
|
||||
setStatus?: (status: {
|
||||
inCall: boolean;
|
||||
speaking: boolean;
|
||||
iconDataUrl?: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
secureStorage?: {
|
||||
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
|
||||
load?: (key: string) => Promise<string | null>;
|
||||
save?: (key: string, value: string) => Promise<void>;
|
||||
delete?: (key: string) => Promise<void>;
|
||||
clear?: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
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: {
|
||||
cache: {},
|
||||
theme: {},
|
||||
licenses: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"exports": {
|
||||
"./session": "./src/session.tsx",
|
||||
"./context": "./src/context.tsx",
|
||||
"./indexed-db": "./src/indexed-db.ts"
|
||||
"./secure": "./src/secure.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
|
|
|
|||
|
|
@ -3,17 +3,32 @@ import {
|
|||
type Storage as StorageSchema,
|
||||
storageDefaults as defaults,
|
||||
} from "@tensamin/shared/data";
|
||||
import { getEntry, setEntry, deleteEntry } from "./indexed-db";
|
||||
import {
|
||||
deleteDatabaseEntry,
|
||||
getDatabaseEntry,
|
||||
setDatabaseEntry,
|
||||
} from "@tensamin/shared/indexedDb";
|
||||
import { ErrorScreen } from "@tensamin/ui";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import {
|
||||
decodeSecureValue,
|
||||
encodeSecureValue,
|
||||
getSecureStorageStatus,
|
||||
isSecureEnvelope,
|
||||
type SecureStorageStatus,
|
||||
} from "./secure";
|
||||
|
||||
export type SaveOptions = { secure?: boolean };
|
||||
|
||||
interface StorageContextValue {
|
||||
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
options?: SaveOptions,
|
||||
): Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
secureStorage: SecureStorageStatus | null;
|
||||
}
|
||||
|
||||
const StorageContext = React.createContext<StorageContextValue | undefined>(
|
||||
|
|
@ -30,96 +45,151 @@ const isIndexedDBSupported = typeof indexedDB !== "undefined";
|
|||
export default function StorageProvider(props: { children: React.ReactNode }) {
|
||||
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
|
||||
const storageRef = React.useRef(storage);
|
||||
const loadedKeys = React.useRef(new Set<keyof StorageSchema>());
|
||||
const loadPromises = React.useRef(
|
||||
new Map<keyof StorageSchema, Promise<StorageSchema[keyof StorageSchema]>>(),
|
||||
);
|
||||
const generations = React.useRef(new Map<keyof StorageSchema, number>());
|
||||
const [secureStorage, setSecureStorage] =
|
||||
React.useState<SecureStorageStatus | null>(null);
|
||||
const secureStorageRef = React.useRef<SecureStorageStatus | null>(null);
|
||||
|
||||
const [error, setError] = React.useState("");
|
||||
const [errorDescription, setErrorDescription] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
storageRef.current = storage;
|
||||
}, [storage]);
|
||||
void getSecureStorageStatus().then((status) => {
|
||||
secureStorageRef.current = status;
|
||||
setSecureStorage(status);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadIO = React.useCallback(
|
||||
const commit = React.useCallback(
|
||||
<K extends keyof StorageSchema>(key: K, nextValue: StorageSchema[K]) => {
|
||||
const next = { ...storageRef.current, [key]: nextValue };
|
||||
storageRef.current = next;
|
||||
loadedKeys.current.add(key);
|
||||
setStorage(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> => {
|
||||
let stored: StorageSchema[K] | undefined;
|
||||
if (loadedKeys.current.has(key)) return storageRef.current[key];
|
||||
const pending = loadPromises.current.get(key);
|
||||
if (pending) return pending as Promise<StorageSchema[K]>;
|
||||
|
||||
const generation = generations.current.get(key) ?? 0;
|
||||
const request = (async () => {
|
||||
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) {
|
||||
setError("Failed to load data");
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
loadPromises.current.delete(key);
|
||||
}
|
||||
|
||||
if (stored !== undefined) {
|
||||
setStorage((prev) => ({ ...prev, [key]: stored }));
|
||||
return stored;
|
||||
}
|
||||
|
||||
return defaults[key];
|
||||
})();
|
||||
loadPromises.current.set(
|
||||
key,
|
||||
request as Promise<StorageSchema[keyof StorageSchema]>,
|
||||
);
|
||||
return request;
|
||||
},
|
||||
[],
|
||||
[commit],
|
||||
);
|
||||
|
||||
const saveIO = React.useCallback(
|
||||
const save = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
options: SaveOptions = {},
|
||||
): Promise<void> => {
|
||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||
await deleteEntry(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: defaults[key] }));
|
||||
if (desktopStorage?.delete)
|
||||
await desktopStorage.delete(String(key)).catch(() => undefined);
|
||||
await deleteDatabaseEntry("storage", key);
|
||||
commit(key, defaults[key]);
|
||||
} else {
|
||||
await setEntry(key, value);
|
||||
setStorage((prev) => ({ ...prev, [key]: value }));
|
||||
const status = options.secure
|
||||
? (secureStorageRef.current ?? (await getSecureStorageStatus()))
|
||||
: secureStorageRef.current;
|
||||
if (
|
||||
options.secure &&
|
||||
status?.backend === "electron-keyring" &&
|
||||
desktopStorage?.save
|
||||
) {
|
||||
await desktopStorage.save(String(key), JSON.stringify(value));
|
||||
await deleteDatabaseEntry("storage", key);
|
||||
} else {
|
||||
const persisted = options.secure
|
||||
? await encodeSecureValue(value)
|
||||
: value;
|
||||
await setDatabaseEntry("storage", key, persisted as StorageSchema[K]);
|
||||
}
|
||||
commit(key, value);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[commit],
|
||||
);
|
||||
|
||||
const clear = React.useCallback(async () => {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
for (const key of keys) {
|
||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||
}
|
||||
await Promise.all(
|
||||
keys.map((key) => deleteDatabaseEntry("storage", key)),
|
||||
);
|
||||
await window.tensaminDesktop?.secureStorage
|
||||
?.clear?.()
|
||||
.catch(() => undefined);
|
||||
loadedKeys.current.clear();
|
||||
loadPromises.current.clear();
|
||||
storageRef.current = defaults;
|
||||
setStorage(defaults);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<StorageContextValue>(
|
||||
() => ({
|
||||
async load<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> {
|
||||
const current = storageRef.current[key];
|
||||
|
||||
if (
|
||||
current === undefined ||
|
||||
JSON.stringify(current) === JSON.stringify(defaults[key])
|
||||
) {
|
||||
const loadedValue = await loadIO(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: loadedValue }));
|
||||
return loadedValue;
|
||||
}
|
||||
|
||||
return current;
|
||||
},
|
||||
|
||||
async save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
nextValue: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
await saveIO(key, nextValue);
|
||||
},
|
||||
|
||||
async clear() {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
await Promise.all(keys.map((key) => deleteEntry(key)));
|
||||
setStorage(defaults);
|
||||
},
|
||||
load,
|
||||
save,
|
||||
clear,
|
||||
secureStorage,
|
||||
}),
|
||||
[loadIO, saveIO],
|
||||
[clear, load, save, secureStorage],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// @ts-expect-error development utility
|
||||
window.save = value.save;
|
||||
}, [value]);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
import { useStorage } from "./context";
|
||||
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
|
||||
import { createCache } from "@tensamin/cache";
|
||||
import { secureValueCodec } from "./secure";
|
||||
|
||||
interface SessionContextType {
|
||||
contacts: Contacts;
|
||||
|
|
@ -21,11 +23,13 @@ interface SessionContextType {
|
|||
const SessionContext = createContext<SessionContextType | undefined>(undefined);
|
||||
|
||||
export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const { freshContacts, freshCommunities, freshCalls } = useMTP();
|
||||
const { freshContacts, freshCommunities, freshCalls, contextReady } =
|
||||
useMTP();
|
||||
const { load, save } = useStorage();
|
||||
const [contacts, setContacts] = useState<Contacts>([]);
|
||||
const [communities, setCommunities] = useState<Communities>([]);
|
||||
const [localCalls, setLocalCalls] = useState<Calls>([]);
|
||||
const [accountId, setAccountId] = useState<number | null>(null);
|
||||
const calls = [
|
||||
...freshCalls,
|
||||
...localCalls.filter(
|
||||
|
|
@ -33,17 +37,27 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
),
|
||||
];
|
||||
|
||||
// Cached session data fills in items the server did not return freshly.
|
||||
useEffect(() => {
|
||||
load("cached_contacts").then((cachedData) => {
|
||||
setContacts([
|
||||
...freshContacts,
|
||||
...cachedData.filter(
|
||||
(item) =>
|
||||
!freshContacts.some((fresh) => fresh.UserId === item.UserId),
|
||||
),
|
||||
]);
|
||||
void load("user_id").then(setAccountId);
|
||||
}, [load]);
|
||||
|
||||
// Cached contacts seed the session, then authenticated server data replaces them.
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
const cache = createCache(String(accountId), {
|
||||
codec: secureValueCodec,
|
||||
});
|
||||
void cache.contacts.get().then((cached) => {
|
||||
if (cached) setContacts(cached);
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !contextReady) return;
|
||||
setContacts(freshContacts);
|
||||
}, [accountId, contextReady, freshContacts]);
|
||||
|
||||
useEffect(() => {
|
||||
load("cached_communities").then((cachedData) => {
|
||||
if (cachedData && freshCommunities) {
|
||||
setCommunities([
|
||||
|
|
@ -57,11 +71,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
]);
|
||||
}
|
||||
});
|
||||
}, [load, freshContacts, freshCommunities]);
|
||||
|
||||
useEffect(() => {
|
||||
save("cached_contacts", contacts);
|
||||
}, [contacts, save]);
|
||||
}, [load, freshCommunities]);
|
||||
useEffect(() => {
|
||||
save("cached_communities", communities);
|
||||
}, [communities, save]);
|
||||
|
|
@ -72,8 +82,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
(contact) => contact.UserId === userId,
|
||||
);
|
||||
if (userIndex === -1) return prevContacts;
|
||||
const [user] = prevContacts.splice(userIndex, 1);
|
||||
return [user, ...prevContacts];
|
||||
const user = prevContacts[userIndex];
|
||||
return [
|
||||
user,
|
||||
...prevContacts.slice(0, userIndex),
|
||||
...prevContacts.slice(userIndex + 1),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { useMTP } from "@tensamin/mtp";
|
|||
|
||||
import { mtp as schemas } from "@tensamin/shared/data";
|
||||
import type z from "zod";
|
||||
import { createCache } from "@tensamin/cache";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
|
||||
export type User = z.infer<typeof schemas.GetUserData.response>;
|
||||
|
||||
|
|
@ -24,6 +27,15 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
|||
);
|
||||
|
||||
const { send } = useMTP();
|
||||
const { load } = useStorage();
|
||||
const { contacts } = useSession();
|
||||
const [accountId, setAccountId] = React.useState<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load("user_id").then((accountId) => {
|
||||
setAccountId(accountId);
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
/**
|
||||
* Executes get.
|
||||
|
|
@ -36,17 +48,17 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
|||
throw new Error("userId is required");
|
||||
}
|
||||
|
||||
const cachedUser = storageRef.current[userId];
|
||||
if (cachedUser !== undefined) {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
const pendingUser = pendingRef.current[userId];
|
||||
if (pendingUser !== undefined) {
|
||||
return pendingUser;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const cache = accountId ? createCache(String(accountId)) : null;
|
||||
const cached =
|
||||
storageRef.current[userId] ?? (await cache?.profiles.get(userId));
|
||||
if (cached) storageRef.current[userId] = cached;
|
||||
try {
|
||||
const userData = await send("GetUserData", { UserId: userId });
|
||||
const user = {
|
||||
...userData.data,
|
||||
|
|
@ -54,9 +66,12 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
|||
? `data:image/webp;base64,${atob(userData.data.Avatar)}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
storageRef.current[userId] = user;
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
pendingRef.current[userId] = request;
|
||||
|
|
@ -67,9 +82,14 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
|||
delete pendingRef.current[userId];
|
||||
}
|
||||
},
|
||||
[send],
|
||||
[accountId, send],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!accountId) return;
|
||||
for (const contact of contacts) void get(contact.UserId);
|
||||
}, [accountId, contacts, get]);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ get }}>
|
||||
{props.children}
|
||||
|
|
|
|||
8452
pnpm-lock.yaml
generated
8452
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
2
todo.md
2
todo.md
|
|
@ -1,4 +1,4 @@
|
|||
- Move legal to extra onboarding package
|
||||
- Add a bunch of tests
|
||||
- Add packages/cache/ to handle caching
|
||||
- Add packages/hotkeys/
|
||||
- Full accessability
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ type_maps:
|
|||
MessageChunk: 61
|
||||
MessageGet: 143
|
||||
MessagesGet: 62
|
||||
MessageDelete: 149
|
||||
PushNotification: 63
|
||||
ReadNotification: 64
|
||||
GetNotifications: 65
|
||||
|
|
@ -148,6 +149,7 @@ type_maps:
|
|||
MessageReactionAdd: 146
|
||||
MessageReactionRemove: 147
|
||||
MessageReactionLive: 148
|
||||
MessageDeleteLive: 150
|
||||
DataTypes:
|
||||
ErrorType: 32
|
||||
ErrorProtocol: 33
|
||||
|
|
@ -265,3 +267,4 @@ type_maps:
|
|||
Edited: 155
|
||||
Reactions: 156
|
||||
Reaction: 157
|
||||
ReplyId: 158
|
||||
|
|
|
|||
Loading…
Reference in a new issue