(feat): migrate ttp to mtp
Some checks failed
/ build-desktop (linux) (push) Failing after 3m58s
/ build-web (push) Failing after 4m13s
/ build-mobile (push) Failing after 6m34s
/ release (push) Has been skipped

(wip): crypto migration
This commit is contained in:
Alois 2026-07-05 01:43:06 +02:00
commit 930663d495
30 changed files with 559 additions and 749 deletions

View file

@ -21,5 +21,5 @@ export function initTray() {
tray.on("click", (event) => { tray.on("click", (event) => {
console.log(event); console.log(event);
}) });
} }

View file

@ -19,13 +19,13 @@ export function Basic({
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
return ( return (
<Card className="animate-in fade-in duration-300 rounded-xl py-0 m-px"> <Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2"> <CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
<div className="relative shrink-0 overflow-visible"> <div className="relative shrink-0 overflow-visible">
<Avatar> <Avatar>
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback> <AvatarFallback>
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<Tooltip> <Tooltip>
@ -49,7 +49,7 @@ export function Basic({
</Tooltip> </Tooltip>
</div> </div>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]"> <div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
<p>{user.display}</p> <p>{user.Display}</p>
</div> </div>
<div className="pr-1">{extra}</div> <div className="pr-1">{extra}</div>
</CardHeader> </CardHeader>
@ -58,5 +58,5 @@ export function Basic({
} }
export function Loading() { export function Loading() {
return <Skeleton className="h-12.5 rounded-2xl" />; return <Skeleton className="h-12.5! rounded-2xl" />;
} }

View file

@ -30,7 +30,7 @@ export default function Profile({ user }: { user: User }) {
void (async () => { void (async () => {
try { try {
const ownId = await load("user_id"); const ownId = await load("user_id");
const privateKey = await load("private_key"); const privateKey = await load("mtp_keyring");
const ownData = await get(ownId); const ownData = await get(ownId);
const secret = await getSharedSecret( const secret = await getSharedSecret(
privateKey, privateKey,
@ -61,17 +61,17 @@ export default function Profile({ user }: { user: User }) {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Avatar className="size-10"> <Avatar className="size-10">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback className="text-lg"> <AvatarFallback className="text-lg">
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-lg font-semibold">{user.display}</p> <p className="text-lg font-semibold">{user.Display}</p>
<p className="text-muted-foreground">{user.username}</p> <p className="text-muted-foreground">{user.Username}</p>
</div> </div>
</div> </div>
<Text value={user.about || ""} /> <Text value={user.About || ""} />
<Button <Button
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline" className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
variant="link" variant="link"

View file

@ -72,7 +72,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
userId={id} userId={id}
component={(user) => component={(user) =>
isMobile ? ( isMobile ? (
<p className="font-medium text-[1.07rem]">{user?.display}</p> <p className="font-medium text-[1.07rem]">{user?.Display}</p>
) : ( ) : (
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}> <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger <PopoverTrigger
@ -85,7 +85,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
}} }}
> >
<p className="font-medium text-[1.07rem]"> <p className="font-medium text-[1.07rem]">
{user?.display} {user?.Display}
</p> </p>
</Button> </Button>
} }

View file

@ -24,7 +24,7 @@ const fetchedUser = z.object({
const formSchema = z.object({ const formSchema = z.object({
username: z.string().min(1).max(255), username: z.string().min(1).max(255),
private_key: z.string().min(1).max(92), mtp_keyring: z.string().min(1).max(92),
}); });
/** /**
@ -61,9 +61,7 @@ function parseTuFileContent(rawFileContent: string): {
? Number(userIdString.split("@")[0]) ? Number(userIdString.split("@")[0])
: Number(userIdString); : Number(userIdString);
const domain = userIdString.includes("@") const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
? userIdString.split("@")[1]
: null;
if (!userId || !privateKey) { if (!userId || !privateKey) {
throw new Error("Invalid file"); throw new Error("Invalid file");
@ -97,9 +95,9 @@ export default function Form() {
await save("session_id", Date.now()); await save("session_id", Date.now());
await save("user_id", parsed.userId); await save("user_id", parsed.userId);
await save("private_key", parsed.privateKey); await save("mtp_keyring", parsed.privateKey);
if (parsed.domain) { if (parsed.domain) {
await save("mtp_url", `https://${parsed.domain}/`); await save("omega_url", `https://${parsed.domain}/`);
} }
location.href = "/"; location.href = "/";
@ -240,9 +238,9 @@ export default function Form() {
await save("session_id", Date.now()); await save("session_id", Date.now());
await save("user_id", user.user_id); await save("user_id", user.user_id);
await save("private_key", inputParse.data.private_key); await save("mtp_keyring", inputParse.data.mtp_keyring);
if (domain) { if (domain) {
await save("mtp_url", `https://${domain}/`); await save("omega_url", `https://${domain}/`);
} }
location.href = "/"; location.href = "/";
@ -273,10 +271,10 @@ export default function Form() {
await save("session_id", Date.now()); await save("session_id", Date.now());
await save("user_id", userId); await save("user_id", userId);
await save("private_key", privateKey); await save("mtp_keyring", privateKey);
if (domain) { if (domain) {
await save("mtp_url", `https://${domain}/`); await save("omega_url", `https://${domain}/`);
} }
location.href = "/"; location.href = "/";
@ -335,8 +333,8 @@ export default function Form() {
<Input required type="text" id="username" name="username" /> <Input required type="text" id="username" name="username" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="private_key">Private Key</Label> <Label htmlFor="mtp_keyring">MTP Keyring</Label>
<Input required type="password" id="private_key" name="private_key" /> <Input required type="password" id="mtp_keyring" name="mtp_keyring" />
</div> </div>
<Button className="mt-auto" type="submit"> <Button className="mt-auto" type="submit">
Login Login

View file

@ -40,9 +40,7 @@ import type z from "zod";
import { mtp } from "@tensamin/shared/data"; import { mtp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
type OnlineStatus = z.infer< type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
typeof mtp.get_user_data.response.shape.OnlineStatus
>;
const onlineStatusLabels: Record<OnlineStatus, string> = { const onlineStatusLabels: Record<OnlineStatus, string> = {
user_online: "Online", user_online: "Online",
@ -88,7 +86,7 @@ function StatusDialog({
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.OnlineStatus);
setErrorMessage(""); setErrorMessage("");
setSaveSucceeded(false); setSaveSucceeded(false);
@ -147,8 +145,7 @@ function StatusDialog({
OnlineStatus: draftOnlineStatus, OnlineStatus: draftOnlineStatus,
}; };
const validation = const validation = mtp.ChangeUserData.request.safeParse(payload);
mtp.change_user_data.request.safeParse(payload);
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -159,7 +156,7 @@ function StatusDialog({
} }
try { try {
await send("change_user_data", validation.data); await send("ChangeUserData", validation.data);
setSaveSucceeded(true); setSaveSucceeded(true);
setErrorMessage(""); setErrorMessage("");
} catch (err) { } catch (err) {
@ -229,7 +226,7 @@ export default function Sidebar() {
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setDraftStatus(user.status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.OnlineStatus);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
@ -248,7 +245,7 @@ export default function Sidebar() {
open={dialogOpen} open={dialogOpen}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.OnlineStatus);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);

View file

@ -55,8 +55,8 @@ function AddConversationButton() {
} }
// user existence check // user existence check
const user = await send("get_user_data", { const user = await send("GetUserData", {
username: result.data, Username: result.data,
}) })
.then((data) => { .then((data) => {
if (data.data.UserId === 0) { if (data.data.UserId === 0) {
@ -80,7 +80,7 @@ function AddConversationButton() {
// add the conv // add the conv
const timeout = setTimeout(() => setLoading(true), 500); const timeout = setTimeout(() => setLoading(true), 500);
send("add_conversation", { send("AddConversation", {
ChatPartnerName: result.data, ChatPartnerName: result.data,
}) })
.then(() => { .then(() => {

View file

@ -51,7 +51,7 @@ export default function Page() {
const avatarUploadRef = useRef<HTMLInputElement>(null); const avatarUploadRef = useRef<HTMLInputElement>(null);
const draftInitializedRef = useRef(false); const draftInitializedRef = useRef(false);
const effectiveAvatar = const effectiveAvatar =
draftUser.avatar === "none" ? undefined : draftUser.avatar; draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
const updateDraftUser = ( const updateDraftUser = (
updater: (previous: Partial<User>) => Partial<User>, updater: (previous: Partial<User>) => Partial<User>,
@ -102,8 +102,8 @@ export default function Page() {
<Avatar className="size-14"> <Avatar className="size-14">
<AvatarImage src={effectiveAvatar} /> <AvatarImage src={effectiveAvatar} />
<AvatarFallback className="text-2xl"> <AvatarFallback className="text-2xl">
{draftUser.display?.slice(0, 2).toUpperCase() || {draftUser.Display?.slice(0, 2).toUpperCase() ||
currentUser.display.slice(0, 2).toUpperCase()} currentUser.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -140,7 +140,7 @@ export default function Page() {
})) }))
} }
placeholder="Display Name" placeholder="Display Name"
value={draftUser.display || ""} value={draftUser.Display || ""}
/> />
<Input <Input
className="w-full" className="w-full"
@ -151,7 +151,7 @@ export default function Page() {
})) }))
} }
placeholder="Username" placeholder="Username"
value={draftUser.username || ""} value={draftUser.Username || ""}
/> />
<MDInput <MDInput
styled styled
@ -162,23 +162,23 @@ export default function Page() {
setValue={(value) => setValue={(value) =>
updateDraftUser((prev) => ({ ...prev, about: value })) updateDraftUser((prev) => ({ ...prev, about: value }))
} }
value={draftUser.about || ""} value={draftUser.About || ""}
/> />
<Button <Button
onClick={async () => { onClick={async () => {
const { avatar, ...draftUsersWithoutAvatar } = draftUser; const { Avatar, ...draftUsersWithoutAvatar } = draftUser;
const payload = { const payload = {
...draftUsersWithoutAvatar, ...draftUsersWithoutAvatar,
...(typeof avatar === "string" ...(typeof Avatar === "string"
? { ? {
avatar: avatar.startsWith("data:") avatar: Avatar.startsWith("data:")
? (avatar.split(",", 2)[1] ?? "") ? (Avatar.split(",", 2)[1] ?? "")
: avatar, : Avatar,
} }
: {}), : {}),
}; };
const validation = mtp.change_user_data.request.safeParse(payload); const validation = mtp.ChangeUserData.request.safeParse(payload);
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -189,7 +189,7 @@ export default function Page() {
} }
try { try {
await send("change_user_data", validation.data); await send("ChangeUserData", validation.data);
setSaveSucceeded(true); setSaveSucceeded(true);
setErrorMessage(""); setErrorMessage("");
} catch (err) { } catch (err) {

View file

@ -1,124 +1,83 @@
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { Button, Input, Label } from "@tensamin/ui";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import QRCode from "qrcode";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@tensamin/ui";
export default function Page() { export default function Page() {
return ( const { save, load } = useStorage();
<div> const [draftMtpUrl, setDraftMtpUrl] = useState("");
<QrCodeLogin /> const [currentMtpUrl, setCurrentMtpUrl] = useState("");
</div>
);
}
// QR Code Login const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
const generateQR = async (text: string): Promise<string> => { const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState("");
try { const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
const url = await QRCode.toDataURL(text, { useState("");
errorCorrectionLevel: "H", const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
margin: 1, useState("");
color: {
dark: "#000000FF",
light: "#FFFFFFFF",
},
});
return url;
} catch (err) {
console.error(err);
throw err;
}
};
function QrCodeLogin() {
const { load } = useStorage();
const [userId, setUserId] = useState<number>(0);
const [privateKey, setPrivateKey] = useState<string>("");
const [qrCodeBase64, setQrCodeBase64] = useState<string | undefined>(
undefined,
);
const [connectionString, setConnectionString] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
load("private_key").then((value) => { load("omega_url").then((value) => {
if (value) { setDraftMtpUrl(value);
setPrivateKey(value); setCurrentMtpUrl(value);
}
}); });
load("user_id").then((value) => { load("forced_omikron_url").then((value) => {
if (value) { setDraftForcedOmikronUrl(value || "");
setUserId(value); setForcedForcedOmikronUrl(value || "");
} });
load("forced_omikron_public_key").then((value) => {
setDraftForcedOmikronPublicKey(value || "");
setForcedForcedOmikronPublicKey(value || "");
}); });
load("mtp_url")
.then((value) => {
if (value) {
const url = new URL(value);
setConnectionString(`@${url.host}`);
} else {
setConnectionString("");
}
})
.catch(() => setConnectionString(""));
}, [load]); }, [load]);
useEffect(() => {
if (userId && privateKey && connectionString !== null) {
generateQR(
`tensamin://tu::${userId}${connectionString}::${privateKey}`,
).then(setQrCodeBase64);
}
}, [userId, privateKey, connectionString]);
const [qrCodeVisible, setQrCodeVisible] = useState(false);
return ( return (
<div className="flex flex-col gap-1 pl-2"> <div className="flex flex-col gap-8">
<p>Login QR Code</p> <p className="text-destructive">
<div className="relative rounded-lg w-50 border-3 aspect-square overflow-hidden"> It's best not to touch these! They can be exploited to gain access to
{qrCodeBase64 && ( your account!
<img className="w-full h-full" src={qrCodeBase64} alt="QR Code" /> </p>
)} <div className="flex flex-col gap-2">
{!qrCodeVisible && ( <Label>Omega Url</Label>
<> <div className="flex gap-1">
<div className="absolute top-0 left-0 w-full h-full backdrop-blur-sm bg-background/50" /> <Input
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center"> value={draftMtpUrl}
<AlertDialog> onChange={(e) => setDraftMtpUrl(e.target.value)}
<AlertDialogTrigger render={<Button>Show QR Code</Button>} /> />
<AlertDialogContent> <Button
<AlertDialogHeader> disabled={currentMtpUrl === draftMtpUrl}
<AlertDialogTitle>Are you sure?</AlertDialogTitle> onClick={() => {
<AlertDialogDescription> save("omega_url", draftMtpUrl);
Exposing this QR code is the same as sharing your .tu }}
file. Since changing your private key is quite tedious, >
only do this when you're confident the key won't be Save
compromised. </Button>
</AlertDialogDescription> </div>
</AlertDialogHeader> </div>
<AlertDialogFooter> <div className="flex flex-col gap-2">
<AlertDialogCancel>Cancel</AlertDialogCancel> <Label>Forced Omikron</Label>
<AlertDialogAction <div className="flex gap-1">
render={ <Input
<Button onClick={() => setQrCodeVisible(true)}> placeholder="URL..."
Show QR Code value={draftForcedOmikronUrl || ""}
</Button> onChange={(e) => setDraftForcedOmikronUrl(e.target.value)}
} />
/> <Input
</AlertDialogFooter> placeholder="Public Key..."
</AlertDialogContent> value={draftForcedOmikronPublicKey || ""}
</AlertDialog> onChange={(e) => setDraftForcedOmikronPublicKey(e.target.value)}
</div> />
</> <Button
)} disabled={
currentForcedOmikronUrl === draftForcedOmikronUrl &&
currentForcedOmikronPublicKey === draftForcedOmikronPublicKey
}
onClick={() => {
save("forced_omikron_url", draftForcedOmikronUrl);
save("forced_omikron_public_key", draftForcedOmikronPublicKey);
}}
>
Save
</Button>
</div>
</div> </div>
</div> </div>
); );

View file

@ -78,7 +78,7 @@ export default function InviteButton({
}); });
}} }}
> >
{user.display} {user.Display}
</Button> </Button>
)} )}
/> />

View file

@ -28,12 +28,12 @@ export default function InvitePopup({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80"> <DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80">
<Avatar className="size-30"> <Avatar className="size-30">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback className="text-5xl"> <AvatarFallback className="text-5xl">
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<p className="text-xl font-medium">{user.display}</p> <p className="text-xl font-medium">{user.Display}</p>
<div className="w-full flex justify-center gap-3"> <div className="w-full flex justify-center gap-3">
<Button <Button
className="w-14 h-14" className="w-14 h-14"

View file

@ -145,7 +145,7 @@ function Overlay({
</TransparentButton> </TransparentButton>
)} )}
<TransparentButton> <TransparentButton>
<p className="text-sm">{user.display}</p> <p className="text-sm">{user.Display}</p>
</TransparentButton> </TransparentButton>
</div> </div>
); );
@ -209,14 +209,14 @@ export default function Base({
}, [participant, get]); }, [participant, get]);
useEffect(() => { useEffect(() => {
if (type !== "user" || !user?.avatar) { if (type !== "user" || !user?.Avatar) {
setAvatarBackgroundColor(undefined); setAvatarBackgroundColor(undefined);
return; return;
} }
let active = true; let active = true;
void getAverageImageColor(user.avatar).then((color) => { void getAverageImageColor(user.Avatar).then((color) => {
if (active) { if (active) {
setAvatarBackgroundColor(color); setAvatarBackgroundColor(color);
} }
@ -225,7 +225,7 @@ export default function Base({
return () => { return () => {
active = false; active = false;
}; };
}, [type, user?.avatar]); }, [type, user?.Avatar]);
// Avatar calc // Avatar calc
const currentCard = useRef<HTMLDivElement>(null); const currentCard = useRef<HTMLDivElement>(null);
@ -342,13 +342,13 @@ export default function Base({
height: "32cqh", height: "32cqh",
}} }}
> >
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback <AvatarFallback
style={{ style={{
fontSize: "11cqh", fontSize: "11cqh",
}} }}
> >
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}

View file

@ -75,9 +75,9 @@ export default function TopBar() {
<TooltipTrigger <TooltipTrigger
render={ render={
<Avatar className="size-7"> <Avatar className="size-7">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback className="text-xs"> <AvatarFallback className="text-xs">
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
} }
@ -86,7 +86,7 @@ export default function TopBar() {
side="bottom" side="bottom"
portalProps={{ container: portalContainer }} portalProps={{ container: portalContainer }}
> >
{user.display} {user.Display}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>

View file

@ -52,7 +52,7 @@ type IncomingCallInvite = {
senderId: number; senderId: number;
}; };
type CurrentCallData = type CurrentCallData =
(z.infer<typeof mtp.call_data.response> & { exists: boolean }) | null; (z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type NavigateFn = (options: { type NavigateFn = (options: {
to: string; to: string;
@ -626,7 +626,7 @@ export async function sendCallInvite(userId: number) {
} }
const ownUserId = (await runtime.load("user_id")) as number; const ownUserId = (await runtime.load("user_id")) as number;
const privateKey = await runtime.load("private_key"); const privateKey = await runtime.load("mtp_keyring");
const ownPublicKey = await runtime const ownPublicKey = await runtime
.getUser(ownUserId) .getUser(ownUserId)
.then((data) => data.PublicKey); .then((data) => data.PublicKey);
@ -897,7 +897,7 @@ export async function joinCall(
if (callSecret) { if (callSecret) {
try { try {
const sharedSecret = await runtime.getSharedSecret( const sharedSecret = await runtime.getSharedSecret(
await runtime.load("private_key"), await runtime.load("mtp_keyring"),
await runtime await runtime
.getUser((await runtime.load("user_id")) as number) .getUser((await runtime.load("user_id")) as number)
.then((res) => res.PublicKey), .then((res) => res.PublicKey),
@ -1178,7 +1178,7 @@ export function useInitializeCall() {
// listen to call invites // listen to call invites
useEffect(() => { useEffect(() => {
subscribePush(async (message) => { subscribePush(async (message) => {
if (message.type !== "call_invite") return; if (message.type !== "CallInvite") return;
const { CallId, CallSecret, SenderId } = message.data as { const { CallId, CallSecret, SenderId } = message.data as {
CallId: string; CallId: string;
@ -1500,10 +1500,10 @@ export function useInitializeCall() {
return; return;
} }
send("call_data", { CallId: callId }) send("CallData", { CallId: callId })
.then((data) => { .then((data) => {
setCurrentCallData({ setCurrentCallData({
...(data.data as z.infer<typeof mtp.call_data.response>), ...(data.data as z.infer<typeof mtp.CallData.response>),
exists: true, exists: true,
}); });
}) })

View file

@ -45,7 +45,7 @@ export default function Preview() {
{data.map((user) => { {data.map((user) => {
return ( return (
<p key={user.UserId} className="text-2xl"> <p key={user.UserId} className="text-2xl">
User: {user.display} User: {user.Display}
</p> </p>
); );
})} })}

View file

@ -11,13 +11,13 @@ import type { RawMessages } from "../values";
*/ */
export async function getMessages( export async function getMessages(
send: BoundSendFn, send: BoundSendFn,
amount: number, Amount: number,
offset: number, Offset: number,
UserId: number, UserId: number,
): Promise<RawMessages> { ): Promise<RawMessages> {
const messages = await send("messages_get", { const messages = await send("MessagesGet", {
amount: amount, Amount,
offset: offset, Offset,
UserId, UserId,
}); });
@ -25,5 +25,5 @@ export async function getMessages(
throw new Error(messages.type); throw new Error(messages.type);
} }
return messages.data.messages; return messages.data.Messages;
} }

View file

@ -72,10 +72,9 @@ export default function InputComponent({
log(3, "chat", "purple", "Message send init, adding live message ..."); log(3, "chat", "purple", "Message send init, adding live message ...");
const reference = addLiveMessage({ const reference = addLiveMessage({
height: 0,
NotEncrypted: true, NotEncrypted: true,
SendTime: time, SendTime: time,
content: currentValue, Content: currentValue,
SentBySelf: true, SentBySelf: true,
MessageState: "awaiting", MessageState: "awaiting",
}); });
@ -94,9 +93,8 @@ export default function InputComponent({
log(3, "chat", "purple", "Content encrypted, sending message..."); log(3, "chat", "purple", "Content encrypted, sending message...");
send("message_send", { send("MessageSend", {
height: 0, Content: encryptedContext,
content: encryptedContext,
ReceiverId: userId, ReceiverId: userId,
SendTime: time, SendTime: time,
}).catch((e) => { }).catch((e) => {

View file

@ -64,14 +64,14 @@ function MessageComponent({
const [isValidURL, setIsValidURL] = useState(false); const [isValidURL, setIsValidURL] = useState(false);
useEffect(() => { useEffect(() => {
try { try {
if (message.content.split(" ").length > 1) throw new Error(); if (message.Content.split(" ").length > 1) throw new Error();
new URL(message.content); new URL(message.Content);
setIsValidURL(true); setIsValidURL(true);
} catch { } catch {
setIsValidURL(false); setIsValidURL(false);
} }
}, [message.content]); }, [message.Content]);
// Message states // Message states
const { load } = useStorage(); const { load } = useStorage();
@ -82,13 +82,13 @@ function MessageComponent({
if (readConfirmations) { if (readConfirmations) {
if (!user?.UserId) return; if (!user?.UserId) return;
await send("message_state", { await send("MessageState", {
ChatPartnerId: user?.UserId, ChatPartnerId: user?.UserId,
SendTime: message.SendTime, SendTime: message.SendTime,
MessageState: "read", MessageState: "read",
}); });
} else { } else {
await send("message_state", { await send("MessageState", {
ChatPartnerId: user?.UserId, ChatPartnerId: user?.UserId,
SendTime: message.SendTime, SendTime: message.SendTime,
MessageState: "received", MessageState: "received",
@ -131,7 +131,7 @@ function MessageComponent({
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`} className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`}
> >
<MessageContextMenu <MessageContextMenu
content={message.content} content={message.Content}
messageId={message.SendTime} messageId={message.SendTime}
> >
<div <div
@ -154,9 +154,9 @@ function MessageComponent({
</p> </p>
) : ( ) : (
<Avatar className="mr-1 mb-auto mt-1 w-10"> <Avatar className="mr-1 mb-auto mt-1 w-10">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback> <AvatarFallback>
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
@ -190,7 +190,7 @@ function MessageComponent({
</Card> </Card>
{!grouped && ( {!grouped && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p className="font-medium">{user.display}</p> <p className="font-medium">{user.Display}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(message.SendTime).toLocaleString([], { {new Date(message.SendTime).toLocaleString([], {
hour: "2-digit", hour: "2-digit",
@ -214,9 +214,9 @@ function MessageComponent({
</div> </div>
)} )}
{isValidURL ? ( {isValidURL ? (
<Media link={message.content} /> <Media link={message.Content} />
) : ( ) : (
<Text value={message.content} /> <Text value={message.Content} />
)} )}
</div> </div>
</> </>
@ -232,8 +232,7 @@ function MessageComponent({
export default React.memo(MessageComponent, (prev, next) => { export default React.memo(MessageComponent, (prev, next) => {
return ( return (
prev.message.SendTime === next.message.SendTime && prev.message.SendTime === next.message.SendTime &&
prev.message.content === next.message.content && prev.message.Content === next.message.Content &&
prev.message.height === next.message.height &&
prev.message.SentBySelf === next.message.SentBySelf && prev.message.SentBySelf === next.message.SentBySelf &&
prev.message.MessageState === next.message.MessageState && prev.message.MessageState === next.message.MessageState &&
prev.message.failed === next.message.failed && prev.message.failed === next.message.failed &&

View file

@ -50,18 +50,16 @@ function updateMessageStateBySendTime<
}; };
} }
/** export default function Provider({ children }: { children: ReactNode }) {
* Executes Provider.
* @param props Parameter props.
* @returns unknown.
*/
export default function Provider(props: { children: ReactNode }) {
const { getSharedSecret, decryptText } = useCrypto(); const { getSharedSecret, decryptText } = useCrypto();
const { get } = useUser(); const { get } = useUser();
const { load } = useStorage(); const { load } = useStorage();
const { send, subscribePush } = useMTP(); const { send, subscribePush } = useMTP();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]); const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
const [currentSharedSecretState, setCurrentSharedSecretState] = useState<{ const [currentSharedSecretState, setCurrentSharedSecretState] = useState<{
userId: number; userId: number;
@ -101,21 +99,40 @@ export default function Provider(props: { children: ReactNode }) {
try { try {
const recipientData = await get(userIdValue); const recipientData = await get(userIdValue);
const ownId = await load("user_id"); const ownId = await load("user_id");
const privateKey = await load("private_key"); const privateKey = await load("mtp_keyring");
const ownData = await get(ownId); const ownData = await get(ownId);
log(3, "chat", "purple", "Getting shared secret...", {
recipientData,
ownData,
});
const sharedSecret = await getSharedSecret( const sharedSecret = await getSharedSecret(
privateKey, privateKey,
ownData.PublicKey, ownData.PublicKey,
recipientData.PublicKey, recipientData.PublicKey,
); );
log(2, "chat", "purple", "Got shared secret", {
sharedSecret,
});
if (active) { if (active) {
setCurrentSharedSecretState({ setCurrentSharedSecretState({
userId: userIdValue, userId: userIdValue,
value: sharedSecret, value: sharedSecret,
}); });
} }
} catch { } catch (err) {
log(
1,
"chat",
"red",
"An unknown error occured while getting a shared secret",
err,
);
setError(err instanceof Error ? err.name : "Unknown Error");
setErrorDescription(err instanceof Error ? err.message : String(err));
if (active) { if (active) {
setCurrentSharedSecretState({ setCurrentSharedSecretState({
userId: userIdValue, userId: userIdValue,
@ -132,9 +149,9 @@ export default function Provider(props: { children: ReactNode }) {
const getMessages = useCallback( const getMessages = useCallback(
async (amount: number, offset: number) => { async (amount: number, offset: number) => {
const messages = await send("messages_get", { const messages = await send("MessagesGet", {
amount, Amount: amount,
offset, Offset: offset,
UserId: userIdValue, UserId: userIdValue,
}); });
@ -142,7 +159,7 @@ export default function Provider(props: { children: ReactNode }) {
throw new Error(messages.type); throw new Error(messages.type);
} }
const rawMessages = messages.data.messages; const rawMessages = messages.data.Messages;
const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime); const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime);
if (sorted.length > 0) { if (sorted.length > 0) {
@ -162,7 +179,7 @@ export default function Provider(props: { children: ReactNode }) {
try { try {
return { return {
...message, ...message,
content: await decryptText(currentSharedSecret, message.content), content: await decryptText(currentSharedSecret, message.Content),
}; };
} catch { } catch {
return message; return message;
@ -214,7 +231,7 @@ export default function Provider(props: { children: ReactNode }) {
// Get live updates for message states // Get live updates for message states
useEffect(() => { useEffect(() => {
return subscribePush((message) => { return subscribePush((message) => {
if (message.type !== "message_state") { if (message.type !== "MessageState") {
return; return;
} }
@ -318,9 +335,11 @@ export default function Provider(props: { children: ReactNode }) {
sharedSecret: currentSharedSecret, sharedSecret: currentSharedSecret,
userId: userIdValue, userId: userIdValue,
inputBoxRef, inputBoxRef,
error,
errorDescription,
}} }}
> >
{props.children} {children}
</context.Provider> </context.Provider>
</QueryClientProvider> </QueryClientProvider>
); );
@ -336,13 +355,10 @@ type contextType = {
sharedSecret: string; sharedSecret: string;
userId: number; userId: number;
inputBoxRef: React.RefObject<HTMLDivElement | null>; inputBoxRef: React.RefObject<HTMLDivElement | null>;
error: string;
errorDescription: string;
}; };
/**
* Executes useChat.
* @param none This function has no parameters.
* @returns contextType.
*/
export function useChat(): contextType { export function useChat(): contextType {
const ctx = useContext(context); const ctx = useContext(context);
if (!ctx) { if (!ctx) {

View file

@ -22,12 +22,6 @@ type MessageChunk = {
startIndex: number; startIndex: number;
}; };
function getEstimatedMessageHeight(message: { height?: number }) {
return typeof message.height === "number" && Number.isFinite(message.height)
? Math.max(1, Math.ceil(message.height))
: FALLBACK_MESSAGE_HEIGHT;
}
function shouldFetchPreviousPage({ function shouldFetchPreviousPage({
entry, entry,
hasNextPage, hasNextPage,
@ -80,8 +74,15 @@ function buildMessageChunks(
* @returns Chat screen JSX. * @returns Chat screen JSX.
*/ */
export default function Screen() { export default function Screen() {
const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } = const {
useChat(); getMessages,
liveMessages,
clearLiveMessages,
userId,
sharedSecret,
error,
errorDescription,
} = useChat();
const { get: getUser } = useUser(); const { get: getUser } = useUser();
const { load } = useStorage(); const { load } = useStorage();
@ -251,16 +252,7 @@ export default function Screen() {
return FALLBACK_MESSAGE_HEIGHT; return FALLBACK_MESSAGE_HEIGHT;
} }
const chunk = messageChunks[index]; return FALLBACK_MESSAGE_HEIGHT;
if (!chunk) {
return FALLBACK_MESSAGE_HEIGHT;
}
return chunk.messages.reduce(
(total, message) => total + getEstimatedMessageHeight(message),
0,
);
}, },
[messageChunks, shouldShowConversationStart], [messageChunks, shouldShowConversationStart],
); );
@ -479,112 +471,122 @@ export default function Screen() {
if (!hasValidChatUser) { if (!hasValidChatUser) {
return ( return (
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80"> <div className="w-full h-full flex flex-col gap-2 items-center justify-center">
Invalid user <p className="font-semibold text-xl">Invalid User</p>
</div> </div>
); );
} }
return ( return (
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden"> <div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<div {error !== "" && errorDescription !== "" ? (
ref={scrollRef} <div className="w-full h-full flex flex-col gap-2 items-center justify-center">
id="chat_container" <p className="font-semibold text-xl">{error}</p>
className="min-h-0 flex-1 overflow-y-auto" <p className="text-muted-foreground text-lg">{errorDescription}</p>
style={{ </div>
overflowAnchor: "none", ) : (
paddingTop: "22px", <>
transform: "scaleY(-1)",
}}
onScroll={handleContainerScroll}
>
<div
className="relative w-full"
style={{ height: `${contentHeight}px` }}
>
<div <div
ref={topSentinelRef} ref={scrollRef}
className="absolute bottom-0 left-0 h-px w-full" id="chat_container"
/> className="min-h-0 flex-1 overflow-y-auto"
{virtualizer.getVirtualItems().map((virtualRow) => { style={{
if ( overflowAnchor: "none",
shouldShowConversationStart && paddingTop: "22px",
virtualRow.index === messageChunks.length transform: "scaleY(-1)",
) { }}
return ( onScroll={handleContainerScroll}
<div >
key="conversation-start" <div
data-index={virtualRow.index} className="relative w-full"
ref={virtualizer.measureElement} style={{ height: `${contentHeight}px` }}
style={{ >
position: "absolute", <div
top: 0, ref={topSentinelRef}
left: 0, className="absolute bottom-0 left-0 h-px w-full"
width: "100%", />
transform: `translateY(${verticalOffset + virtualRow.start}px)`, {virtualizer.getVirtualItems().map((virtualRow) => {
}} if (
> shouldShowConversationStart &&
<div className="w-full flex justify-start scale-y-[-1]"> virtualRow.index === messageChunks.length
<div className="text-sm text-foreground/55 px-2.5"> ) {
Conversation start 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) {
return null;
}
return (
<div
key={chunk.key}
data-index={virtualRow.index}
className="flex flex-col"
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
}}
>
<div className="flex flex-col scale-y-[-1]">
{chunk.messages.map((message, chunkMessageIndex) => {
const messageIndex =
chunk.startIndex + chunkMessageIndex;
const lastMessage = messages[messageIndex - 1];
const isGrouped =
lastMessage &&
lastMessage.SentBySelf === message.SentBySelf &&
Math.round(lastMessage.SendTime / 10000) ===
Math.round(message.SendTime / 10000);
return (
<Message
key={getMessageRenderKey(message)}
grouped={isGrouped}
message={message}
user={
message.SentBySelf
? (messageUsers.own ?? null)
: (messageUsers.peer ?? null)
}
/>
);
})}
</div> </div>
</div> </div>
</div> );
); })}
} </div>
</div>
const chunkIndex = virtualRow.index; <div className="z-10 shrink-0">
const chunk = messageChunks[chunkIndex]; <InputComponent setValue={setValue} value={value} />
if (!chunk) { </div>
return null; </>
} )}
return (
<div
key={chunk.key}
data-index={virtualRow.index}
className="flex flex-col"
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
}}
>
<div className="flex flex-col scale-y-[-1]">
{chunk.messages.map((message, chunkMessageIndex) => {
const messageIndex = chunk.startIndex + chunkMessageIndex;
const lastMessage = messages[messageIndex - 1];
const isGrouped =
lastMessage &&
lastMessage.SentBySelf === message.SentBySelf &&
Math.round(lastMessage.SendTime / 10000) ===
Math.round(message.SendTime / 10000);
return (
<Message
key={getMessageRenderKey(message)}
grouped={isGrouped}
message={message}
user={
message.SentBySelf
? (messageUsers.own ?? null)
: (messageUsers.peer ?? null)
}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
</div>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { mtp } from "@tensamin/shared/data"; import { mtp } from "@tensamin/shared/data";
export type RawMessages = z.infer<typeof mtp.messages_get.response>["messages"]; export type RawMessages = z.infer<typeof mtp.MessagesGet.response>["Messages"];
export type RawMessage = RawMessages[number]; export type RawMessage = RawMessages[number];

View file

@ -1,5 +1,5 @@
import * as React from "react"; import { createContext, useContext } from "react";
import * as Comlink from "comlink"; import { crypto } from "mtp";
type CryptoContextType = { type CryptoContextType = {
decrypt: ( decrypt: (
@ -19,198 +19,26 @@ type CryptoContextType = {
) => Promise<string>; ) => Promise<string>;
}; };
type ApiRef = { export const context = createContext<CryptoContextType | undefined>(undefined);
encrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
decrypt: (
secret: string,
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
decryptText: (secret: string, ciphertext: string) => Promise<string>;
encryptText: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
export function bytesToBase64(bytes: Uint8Array<ArrayBuffer>): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
export function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export const context = React.createContext<CryptoContextType | undefined>(
undefined,
);
/**
* Provides cryptographic actions backed by a worker without coupling to UI state.
* @param props Component props with children.
* @returns Crypto context provider JSX.
*/
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: React.ReactNode }) {
const apiRef = React.useRef<ApiRef | null>(null); return (
<context.Provider
const value = React.useMemo<CryptoContextType>( value={{
() => ({ decrypt: crypto.decrypt,
encrypt: async (secret, plaintext) => { encrypt: crypto.encrypt,
const api = apiRef.current; decryptText: crypto.decryptText,
if (!api) throw new Error("API not initialized"); encryptText: crypto.encryptText,
return await api.encrypt(secret, plaintext); getSharedSecret: crypto.getSharedSecret,
}, }}
decrypt: async (secret, ciphertext) => { >
const api = apiRef.current; {props.children}
if (!api) throw new Error("API not initialized"); </context.Provider>
return await api.decrypt(secret, ciphertext);
},
encryptText: async (secret, plaintext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.encryptText(secret, plaintext);
},
decryptText: async (secret, ciphertext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.decryptText(secret, ciphertext);
},
getSharedSecret: async (ownPrivateKey, ownPublicKey, otherPublicKey) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.getSharedSecret(
ownPrivateKey,
ownPublicKey,
otherPublicKey,
);
},
}),
[],
); );
React.useEffect(() => {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
apiRef.current = Comlink.wrap<ApiRef>(worker);
return () => {
apiRef.current = null;
worker.terminate();
};
}, []);
return <context.Provider value={value}>{props.children}</context.Provider>;
} }
/**
* Creates crypto action functions that safely delegate to the worker API.
* @param getApiRef Function that returns the worker API reference.
* @returns Typed crypto action functions.
*/
export function createCryptoActions(
getApiRef: () => ApiRef | null,
): CryptoContextType {
/**
* Encrypts bytes by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param input Plaintext bytes to encrypt.
* @returns Ciphertext bytes.
*/
const encrypt = async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.encrypt(secret, input);
};
/**
* Decrypts bytes by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param input Ciphertext bytes to decrypt.
* @returns Plaintext bytes.
*/
const decrypt = async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.decrypt(secret, input);
};
/**
* Encrypts plaintext text by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param plaintext Plaintext to encrypt.
* @returns Base64 ciphertext.
*/
const encryptText = async (
secret: string,
plaintext: string,
): Promise<string> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.encryptText(secret, plaintext);
};
/**
* Decrypts base64 ciphertext text by delegating to the crypto worker API.
* @param secret Hex-encoded shared secret.
* @param ciphertext Base64 ciphertext to decrypt.
* @returns Decrypted plaintext.
*/
const decryptText = async (
secret: string,
ciphertext: string,
): Promise<string> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.decryptText(secret, ciphertext);
};
/**
* Derives a shared secret from local and peer key material via the worker API.
* @param ownPrivateKey Local private key.
* @param ownPublicKey Local public key.
* @param otherPublicKey Peer public key.
* @returns Hex-encoded shared secret.
*/
const getSharedSecret = async (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> => {
const api = getApiRef();
if (!api) throw new Error("API not initialized");
return await api.getSharedSecret(
ownPrivateKey,
ownPublicKey,
otherPublicKey,
);
};
return { encrypt, decrypt, encryptText, decryptText, getSharedSecret };
}
/**
* Returns the crypto actions from the nearest provider.
* Throws when used outside of the crypto provider tree.
*/
export function useCrypto(): CryptoContextType { export function useCrypto(): CryptoContextType {
const ctx = React.useContext(context); const ctx = useContext(context);
if (!ctx) { if (!ctx) {
throw new Error("useCrypto must be used within a CryptoProvider"); throw new Error("useCrypto must be used within a CryptoProvider");
} }

View file

@ -12,10 +12,8 @@ import { isTauri } from "@tauri-apps/api/core";
import { onResume } from "tauri-plugin-app-events-api"; import { onResume } from "tauri-plugin-app-events-api";
import { MTPClient } from "mtp"; import { MTPClient } from "mtp";
import { type z } from "zod"; import { type z } from "zod";
import { ConnectionState } from "./values"; import { ConnectionState } from "mtp";
import createAsyncQueue, { import createAsyncQueue from "@tensamin/shared/asyncQueue";
createQueuedFunc,
} from "@tensamin/shared/asyncQueue";
import { toast as sonnerToast } from "@tensamin/ui"; import { toast as sonnerToast } from "@tensamin/ui";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -47,36 +45,6 @@ function base64ToUint8Array(b64: string) {
return out; return out;
} }
const PUSH_TYPES = [
"message_live",
"message_state",
"call_invite",
"error_no_iota",
] as const;
const WIRE_TYPES = {
temp_cool_type: "TempCoolType",
get_user_data: "GetUserData",
change_user_data: "ChangeUserData",
ping: "AppPing",
message_live: "MessageLive",
messages_get: "MessagesGet",
message_send: "MessageSend",
add_conversation: "AddConversation",
message_state: "MessageState",
load_txt_record: "LoadTxtRecord",
authenticate_app: "AuthenticateApp",
create_app: "CreateApp",
call_token: "CallToken",
call_data: "CallData",
call_invite: "CallInvite",
error_no_iota: "ErrorNoIota",
} as const satisfies Record<keyof Schemas & string, string>;
const APP_TYPES = Object.fromEntries(
Object.entries(WIRE_TYPES).map(([appType, wireType]) => [wireType, appType]),
) as Record<string, keyof Schemas & string>;
export type ProtocolMessage< export type ProtocolMessage<
T extends keyof Schemas & string = keyof Schemas & string, T extends keyof Schemas & string = keyof Schemas & string,
> = { > = {
@ -139,10 +107,8 @@ function validateResponse<T extends keyof Schemas & string>(
type: T, type: T,
message: { id?: number; type: string; data: unknown }, message: { id?: number; type: string; data: unknown },
): ProtocolMessage<T> { ): ProtocolMessage<T> {
const appType = APP_TYPES[message.type] ?? message.type; if (message.type.startsWith("Error")) {
return message as ProtocolMessage<T>;
if (appType.startsWith("error")) {
return { ...message, type: appType } as ProtocolMessage<T>;
} }
const schema = schemas[type]?.response; const schema = schemas[type]?.response;
@ -159,7 +125,7 @@ function validateResponse<T extends keyof Schemas & string>(
return { return {
id: message.id, id: message.id,
type: appType, type: message.type,
data: parsed.data, data: parsed.data,
} as ProtocolMessage<T>; } as ProtocolMessage<T>;
} }
@ -192,7 +158,7 @@ export function Provider(props: {
// MTP url // MTP url
const [mtpUrl, setMtpUrl] = useState<string | null>(null); const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
load("mtp_url").then(setMtpUrl); load("omega_url").then(setMtpUrl);
}, [load]); }, [load]);
// Validation override functions // Validation override functions
@ -205,7 +171,7 @@ export function Provider(props: {
} }
const message = await client.request( const message = await client.request(
WIRE_TYPES[type], type,
(data ?? {}) as Record<string, unknown>, (data ?? {}) as Record<string, unknown>,
options, options,
); );
@ -220,7 +186,7 @@ export function Provider(props: {
return () => {}; return () => {};
} }
return client.subscribe(WIRE_TYPES[type], (message) => { return client.subscribe(type, (message) => {
handler(validateResponse(type, message)); handler(validateResponse(type, message));
}); });
}, []); }, []);
@ -231,8 +197,13 @@ export function Provider(props: {
return () => {}; return () => {};
} }
const unsubscribers = PUSH_TYPES.map((type) => const unsubscribers = [
client.subscribe(WIRE_TYPES[type], (message) => { "MessageLive",
"MessageState",
"CallInvite",
"ErrorNoIota",
].map((type) =>
client.subscribe(type, (message) => {
handler(validateResponse(type as keyof Schemas & string, message)); handler(validateResponse(type as keyof Schemas & string, message));
}), }),
); );
@ -242,23 +213,6 @@ export function Provider(props: {
}; };
}, []); }, []);
// No Iota check
useEffect(() => {
if (!connected) return;
return subscribe("error_no_iota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
});
}, [connected, subscribe]);
// Custom Pings // Custom Pings
useEffect(() => { useEffect(() => {
if (!connected || !identified) { if (!connected || !identified) {
@ -268,7 +222,7 @@ export function Provider(props: {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const originalNow = Date.now(); const originalNow = Date.now();
const data = await send("ping", { LastPing: originalNow }); const data = await send("Ping", { LastPing: originalNow });
setOwnPing(Date.now() - originalNow); setOwnPing(Date.now() - originalNow);
const remotePing = data.data.PingIota; const remotePing = data.data.PingIota;
@ -286,6 +240,7 @@ export function Provider(props: {
}, [connected, identified, send]); }, [connected, identified, send]);
// Reconnect stuff // Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => { useEffect(() => {
if (!mtpUrl) return; if (!mtpUrl) return;
@ -309,12 +264,10 @@ export function Provider(props: {
reconnectResetTimer = null; reconnectResetTimer = null;
}; };
let resolveConnection: (() => void) | null = null;
if (!props.blockConnection) { if (!props.blockConnection) {
sonnerToast.promise( sonnerToast.promise(
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
resolveConnection = resolve; resolveConnectionRef.current = resolve;
}), }),
{ {
id: "mtp-connection-toast", id: "mtp-connection-toast",
@ -346,61 +299,64 @@ export function Provider(props: {
await MTPClient.init(); await MTPClient.init();
log(2, "mtp", "purple", "Fetching Omikron data."); const forcedOmikronUrl = await load("forced_omikron_url");
const data = await fetch( const forcedOmikronPublicKey = await load("forced_omikron_public_key");
`${mtpUrl}api/get/omikron/${await load("user_id")}`,
);
if (data.status === 404) { let url = null;
sonnerToast.error("We couldn't reach your Iota", { let omikronPublicKey = null;
description: if (forcedOmikronUrl && forcedOmikronPublicKey) {
"Check your network connection and try restarting your Iota", url = forcedOmikronUrl;
icon: null, omikronPublicKey = forcedOmikronPublicKey;
duration: Infinity, } else {
closeButton: true, log(2, "mtp", "purple", "Fetching Omikron data.");
}); const data = await fetch(
resolveConnection?.(); `${mtpUrl}api/get/omikron/${await load("user_id")}`,
cleanup(); );
return;
if (data.status === 404) {
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
cleanup();
return;
}
const omikronData = (await data.json()) as {
id: number;
ip_address: string;
port: number;
public_key: string;
status: string;
};
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
)
throw new Error("Invalid Omikron data");
url = `https://${omikronData.ip_address}:${omikronData.port}`;
omikronPublicKey = omikronData.public_key;
} }
const omikronData = (await data.json()) as {
id: number;
ip_address: string;
port: number;
public_key: string;
status: string;
};
//codec.decode(new Uint8Array(await res.arrayBuffer())), //codec.decode(new Uint8Array(await res.arrayBuffer())),
if ( if (!url || !omikronPublicKey)
!omikronData.ip_address || throw new Error("Missing Omikron URL or Public Key");
!omikronData.port ||
!omikronData.public_key
)
throw new Error("Invalid Omikron data");
const url = `https://${omikronData.ip_address}:${omikronData.port}`;
log(2, "mtp", "green", "Connecting to: " + url); log(2, "mtp", "green", "Connecting to: " + url);
const client = await MTPClient.create({ const client = await MTPClient.create({
url, url,
storage: {
getItem: (key) => {
console.log(key);
return key;
},
removeItem: (key) => {
console.log(key);
},
setItem: console.log,
},
credentials: { credentials: {
clientId: await load("user_id"), clientId: await load("user_id"),
keyring: base64ToUint8Array(await load("private_key")), keyring: base64ToUint8Array(await load("mtp_keyring")),
}, },
hostPublicKey: omikronData.public_key, hostPublicKey: omikronPublicKey,
descriptor: "client", descriptor: "client",
pings: true, pings: true,
logger: (event) => { logger: (event) => {
@ -410,12 +366,24 @@ export function Provider(props: {
); );
} }
if (event.type !== "Pong") { if (event.type !== "Pong" && event.type !== "Ping") {
log( log(
2, 2,
"mtp", "mtp",
event.type === "state" ? "cyan" : "blue", event.type === "state"
event.type === "state" ? event.data : event.type, ? "purple"
: event.direction === "recv"
? "cyan"
: event.direction === "send"
? "gray"
: "blue",
event.type === "state"
? event.data
: event.direction === "recv"
? "< " + event.type
: event.direction === "send"
? "> " + event.type
: event.type,
event, event,
); );
} }
@ -436,19 +404,22 @@ export function Provider(props: {
return; return;
} }
const authPayload = new Promise<ProtocolMessage<"temp_cool_type">>( const authPayload = new Promise<
(resolve, reject) => { ProtocolMessage<"IdentificationResponse">
const unsubscribe = client.subscribe("TempCoolType", (message) => { >((resolve, reject) => {
const unsubscribe = client.subscribe(
"IdentificationResponse",
(message) => {
try { try {
unsubscribe(); unsubscribe();
resolve(validateResponse("temp_cool_type", message)); resolve(validateResponse("IdentificationResponse", message));
} catch (authPayloadError) { } catch (authPayloadError) {
unsubscribe(); unsubscribe();
reject(authPayloadError); reject(authPayloadError);
} }
}); },
}, );
); });
clearReconnectTimer(); clearReconnectTimer();
@ -467,12 +438,12 @@ export function Provider(props: {
if (disposed || clientRef.current !== client) return; if (disposed || clientRef.current !== client) return;
setFreshContacts(finalResponse.data.contacts); setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.communities ?? []); setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.calls); setFreshCalls(finalResponse.data.Calls);
setIdentifying(false); setIdentifying(false);
setIdentified(true); setIdentified(true);
resolveConnection?.(); resolveConnectionRef.current?.();
} catch (connectError) { } catch (connectError) {
if (disposed) return; if (disposed) return;
cleanup(); cleanup();
@ -492,7 +463,7 @@ export function Provider(props: {
id: "mtp-connection-toast", id: "mtp-connection-toast",
description: description:
connectError instanceof Error connectError instanceof Error
? connectError.message ? connectError.message.split(":")[0]
: String(connectError ?? "Unknown error"), : String(connectError ?? "Unknown error"),
icon: null, icon: null,
duration: Infinity, duration: Infinity,
@ -558,6 +529,24 @@ export function Provider(props: {
}; };
}, [mtpUrl, props.blockConnection, load]); }, [mtpUrl, props.blockConnection, load]);
// No Iota check
useEffect(() => {
if (!connected) return;
return subscribe("ErrorNoIota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
});
}, [connected, subscribe]);
// Async queue // Async queue
const loadingDescription = useMemo(() => { const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details"; if (!mtpUrl) return "Loading connection details";
@ -587,10 +576,18 @@ export function Provider(props: {
} }
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]); }, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
return mtp.send(type, data, options);
},
[mtpRef],
);
return ( return (
<MTPContext.Provider <MTPContext.Provider
value={{ value={{
send: createQueuedFunc(() => (contextReady ? send : null)), send: sendQueued,
subscribe, subscribe,
subscribePush, subscribePush,
readyState, readyState,

View file

@ -6,7 +6,7 @@ import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react"; import { createContext, useEffect, useContext } from "react";
import z from "zod"; import z from "zod";
import { toast as sonnerToast } from "sonner"; import { toast as sonnerToast } from "sonner";
import { message as messageSchema } from "@tensamin/shared/data"; import { Message as MessageSchema } from "@tensamin/shared/data";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui"; import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
@ -34,9 +34,9 @@ export default function Provider(props: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
return subscribePush(async (ttpMessage) => { return subscribePush(async (ttpMessage) => {
if (ttpMessage.type === "message_live") { if (ttpMessage.type === "MessageLive") {
const { message, SenderId } = ttpMessage.data as { const { message, SenderId } = ttpMessage.data as {
message: z.infer<typeof messageSchema>; message: z.infer<typeof MessageSchema>;
SenderId: number; SenderId: number;
}; };
@ -44,18 +44,18 @@ export default function Provider(props: { children: React.ReactNode }) {
const decryptedContent = await decryptText( const decryptedContent = await decryptText(
await getSharedSecret( await getSharedSecret(
await load("private_key"), await load("mtp_keyring"),
await get(await load("user_id")).then((data) => data.PublicKey), await get(await load("user_id")).then((data) => data.PublicKey),
user.PublicKey, user.PublicKey,
), ),
message.content, message.Content,
); );
// Update message state // Update message state
if (userId === SenderId) { if (userId === SenderId) {
addLiveMessage({ addLiveMessage({
...message, ...message,
content: decryptedContent, Content: decryptedContent,
SentBySelf: false, SentBySelf: false,
}); });
return; return;
@ -67,7 +67,7 @@ export default function Provider(props: { children: React.ReactNode }) {
if (await load("settings.receive_confirmations")) { if (await load("settings.receive_confirmations")) {
void send( void send(
"message_state", "MessageState",
{ {
MessageState: "received", MessageState: "received",
}, },
@ -83,10 +83,10 @@ export default function Provider(props: { children: React.ReactNode }) {
const hasPermissions = await requestNotificationPermission(); const hasPermissions = await requestNotificationPermission();
if (hasPermissions) { if (hasPermissions) {
const notification = new Notification(user.display, { const notification = new Notification(user.Display, {
body: decryptedContent, body: decryptedContent,
icon: user.avatar || user.display.slice(0, 2).toUpperCase(), icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
badge: user.avatar || user.display.slice(0, 2).toUpperCase(), badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
tag: `message-${user.UserId}`, tag: `message-${user.UserId}`,
silent: true, silent: true,
}); });
@ -100,16 +100,16 @@ export default function Provider(props: { children: React.ReactNode }) {
notification.close(); notification.close();
}; };
} else { } else {
sonnerToast(user.display, { sonnerToast(user.Display, {
classNames: { classNames: {
content: "pl-4", content: "pl-4",
}, },
description: decryptedContent, description: decryptedContent,
icon: ( icon: (
<Avatar> <Avatar>
<AvatarImage src={user.avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback> <AvatarFallback>
{user.display.slice(0, 2).toUpperCase()} {user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
), ),

View file

@ -12,23 +12,22 @@ const fileFromMessage = z.object({
type: z.enum(["image", "image_top_right", "file"]), type: z.enum(["image", "image_top_right", "file"]),
}); });
export const message = z.object({ export const Message = z.object({
height: z.number(),
NotEncrypted: z.boolean().optional(), NotEncrypted: z.boolean().optional(),
SentBySelf: z.boolean().optional(), SentBySelf: z.boolean().optional(),
SendTime: z.number(), SendTime: z.number(),
content: z.base64(), Content: z.base64(),
files: z.array(fileFromMessage).optional(), Files: z.array(fileFromMessage).optional(),
tint: z.string().length(7).startsWith("#").optional(), Tint: z.string().length(7).startsWith("#").optional(),
avatar: z.boolean().optional(), Avatar: z.boolean().optional(),
display: z.boolean().optional(), Display: z.boolean().optional(),
MessageState: z MessageState: z
.enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use .enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use
.default("received"), .default("received"),
}); });
export const failedUser = { export const failedUser = {
display: "Failed", Display: "Failed",
IotaId: 0, IotaId: 0,
OmikronConnections: [], OmikronConnections: [],
OnlineStatus: "user_borked", OnlineStatus: "user_borked",
@ -36,12 +35,12 @@ export const failedUser = {
SubEnd: 0, SubEnd: 0,
SubLevel: 0, SubLevel: 0,
UserId: 0, UserId: 0,
username: "unknown", Username: "unknown",
} as z.infer<typeof mtp.get_user_data.response>; } as z.infer<typeof mtp.GetUserData.response>;
const authPayload = z.object({ const authPayload = z.object({
communities: z.array(z.object({})).optional(), Communities: z.array(z.object({})).default([]),
contacts: z.array( Contacts: z.array(
z.object({ z.object({
LastMessageAt: z.number(), LastMessageAt: z.number(),
UserId: z.number(), UserId: z.number(),
@ -51,21 +50,21 @@ const authPayload = z.object({
SenderId: z.number(), SenderId: z.number(),
}) })
.optional(), .optional(),
messages: z.array(message), Messages: z.array(Message),
}), }),
), ).default([]),
calls: z.array( Calls: z.array(
z.object({ z.object({
CallId: z.string(), CallId: z.string(),
CallSecret: z.base64().optional(), CallSecret: z.base64().optional(),
CallMembers: z.array(z.number()), CallMembers: z.array(z.number()),
}), }),
), ).default([]),
}); });
export type Contacts = z.infer<typeof authPayload.shape.contacts>; export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
export type Communities = z.infer<typeof authPayload.shape.communities>; export type Communities = z.infer<typeof authPayload.shape.Communities>;
export type Calls = z.infer<typeof authPayload.shape.calls>; export type Calls = z.infer<typeof authPayload.shape.Calls>;
type Base16Palette = Record< type Base16Palette = Record<
| "base00" | "base00"
@ -89,9 +88,9 @@ type Base16Palette = Record<
// MTP // MTP
const user = z.object({ const user = z.object({
about: z.string().max(255).optional(), About: z.string().max(255).optional(),
avatar: z.string().optional(), Avatar: z.string().optional(),
display: z.string().min(1).max(15), Display: z.string().min(1).max(15),
IotaId: z.number(), IotaId: z.number(),
OmikronConnections: z.array(z.number()), OmikronConnections: z.array(z.number()),
OmikronId: z.number().optional(), OmikronId: z.number().optional(),
@ -107,29 +106,29 @@ const user = z.object({
"iota_borked", "iota_borked",
]), ]),
PublicKey: z.base64(), PublicKey: z.base64(),
status: z.string().max(15).optional(), Status: z.string().max(15).optional(),
SubEnd: z.number(), SubEnd: z.number(),
SubLevel: z.number(), SubLevel: z.number(),
UserId: z.number(), UserId: z.number(),
username: z.string().min(1).max(15), Username: z.string().min(1).max(15),
}); });
export const mtp = { export const mtp = {
temp_cool_type: { IdentificationResponse: {
request: z.object({}).optional(), request: z.object({}).optional(),
response: authPayload, response: authPayload,
}, },
get_user_data: { GetUserData: {
request: z.object({ request: z.object({
UserId: z.number().optional(), UserId: z.number().optional(),
username: z.string().optional(), Username: z.string().optional(),
}), }),
response: user, response: user,
}, },
change_user_data: { ChangeUserData: {
request: user.partial(), request: user.partial(),
response: z.object({}), response: z.object({}),
}, },
ping: { Ping: {
request: z.object({ request: z.object({
LastPing: z.number(), LastPing: z.number(),
}), }),
@ -137,76 +136,75 @@ export const mtp = {
PingIota: z.number(), PingIota: z.number(),
}), }),
}, },
message_live: { MessageLive: {
request: z.object({}).optional(), request: z.object({}).optional(),
response: z.object({ response: z.object({
SenderId: z.number(), SenderId: z.number(),
message, Message,
}), }),
}, },
messages_get: { MessagesGet: {
request: z.object({ request: z.object({
UserId: z.number(), UserId: z.number(),
amount: z.number(), Amount: z.number(),
offset: z.number(), Offset: z.number(),
}), }),
response: z.object({ response: z.object({
messages: z.array(message), Messages: z.array(Message),
}), }),
}, },
message_send: { MessageSend: {
request: z.object({ request: z.object({
height: z.number(), Content: z.base64(),
content: z.base64(),
ReceiverId: z.number(), ReceiverId: z.number(),
SendTime: z.number(), SendTime: z.number(),
files: z.array(fileFromMessage).optional(), Files: z.array(fileFromMessage).optional(),
}), }),
response: z.object({}), response: z.object({}),
}, },
add_conversation: { AddConversation: {
request: z.object({ request: z.object({
ChatPartnerId: z.number().optional(), ChatPartnerId: z.number().optional(),
ChatPartnerName: z.string().min(1).max(15).optional(), ChatPartnerName: z.string().min(1).max(15).optional(),
}), }),
response: z.object({}), response: z.object({}),
}, },
message_state: { MessageState: {
request: z request: z
.object({ .object({
ChatPartnerId: z.number(), ChatPartnerId: z.number(),
SendTime: z.number(), SendTime: z.number(),
MessageState: message.shape.MessageState, MessageState: Message.shape.MessageState,
}) })
.or( .or(
z.object({ z.object({
MessageState: message.shape.MessageState, MessageState: Message.shape.MessageState,
}), }),
), ),
response: z.object({ response: z.object({
ChatPartnerId: z.number(), ChatPartnerId: z.number(),
MessageState: message.shape.MessageState, MessageState: Message.shape.MessageState,
SendTime: z.number(), SendTime: z.number(),
}), }),
}, },
load_txt_record: { LoadTxtRecord: {
request: z.object({ request: z.object({
path: z.string(), Path: z.string(),
}), }),
response: z.object({ response: z.object({
content: z.string(), Content: z.string(),
}), }),
}, },
authenticate_app: { AuthenticateApp: {
request: z.object({ request: z.object({
AppIdentifier: z.string(), AppIdentifier: z.string(),
}), }),
response: z.object({ response: z.object({
challenge: z.base64(), Challenge: z.base64(),
}), }),
}, },
create_app: { CreateApp: {
request: z.object({ request: z.object({
AppPublicKey: z.base64(), AppPublicKey: z.base64(),
AppIdentifier: z.string(), AppIdentifier: z.string(),
@ -215,7 +213,7 @@ export const mtp = {
}, },
// Calls // Calls
call_token: { CallToken: {
request: z.object({ request: z.object({
CallId: z.string(), CallId: z.string(),
}), }),
@ -223,7 +221,7 @@ export const mtp = {
CallToken: z.string(), CallToken: z.string(),
}), }),
}, },
call_data: { CallData: {
request: z.object({ request: z.object({
CallId: z.string(), CallId: z.string(),
}), }),
@ -231,7 +229,7 @@ export const mtp = {
UserIds: z.array(z.number()), UserIds: z.array(z.number()),
}), }),
}, },
call_invite: { CallInvite: {
request: z.object({ request: z.object({
CallId: z.string(), CallId: z.string(),
CallSecret: z.base64(), CallSecret: z.base64(),
@ -243,7 +241,7 @@ export const mtp = {
SenderId: z.number().optional(), SenderId: z.number().optional(),
}), }),
}, },
error_no_iota: { ErrorNoIota: {
request: z.object({}).optional(), request: z.object({}).optional(),
response: z.object({}), response: z.object({}),
}, },
@ -255,7 +253,7 @@ export type MTP = typeof mtp;
export interface Storage extends SettingsStorageDefaults { export interface Storage extends SettingsStorageDefaults {
session_id: number; session_id: number;
user_id: number; user_id: number;
private_key: string; mtp_keyring: string;
ppandtos_done: boolean; ppandtos_done: boolean;
accepted_terms_of_service: boolean; accepted_terms_of_service: boolean;
accepted_privacy_policy: boolean; accepted_privacy_policy: boolean;
@ -265,7 +263,9 @@ export interface Storage extends SettingsStorageDefaults {
legal_docs: z.infer<typeof legalDocsSchema>; legal_docs: z.infer<typeof legalDocsSchema>;
cached_contacts: Contacts; cached_contacts: Contacts;
cached_communities: Communities; cached_communities: Communities;
mtp_url: string; omega_url: string;
forced_omikron_url: string | undefined;
forced_omikron_public_key: string | undefined;
call_mute_range_start: number; call_mute_range_start: number;
call_mute_range_end: number; call_mute_range_end: number;
theme_color: string; theme_color: string;
@ -287,7 +287,7 @@ export interface Storage extends SettingsStorageDefaults {
export const storageDefaults: Storage = { export const storageDefaults: Storage = {
session_id: 0, session_id: 0,
user_id: 0, user_id: 0,
private_key: "", mtp_keyring: "",
ppandtos_done: false, ppandtos_done: false,
accepted_terms_of_service: false, accepted_terms_of_service: false,
accepted_privacy_policy: false, accepted_privacy_policy: false,
@ -314,7 +314,9 @@ export const storageDefaults: Storage = {
}, },
cached_contacts: [], cached_contacts: [],
cached_communities: [], cached_communities: [],
mtp_url: "https://omega.tensamin.net", omega_url: "https://omega.tensamin.net",
forced_omikron_url: undefined,
forced_omikron_public_key: undefined,
call_mute_range_start: -55, call_mute_range_start: -55,
call_mute_range_end: -45, call_mute_range_end: -45,
theme_color: "", theme_color: "",
@ -355,7 +357,7 @@ export const storageDefaults: Storage = {
// User Status // User Status
export function getStatusColor( export function getStatusColor(
status: z.infer<typeof mtp.get_user_data.response.shape.OnlineStatus>, status: z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>,
) { ) {
switch (status) { switch (status) {
case "user_online": case "user_online":

View file

@ -86,7 +86,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
const newUser = { const newUser = {
UserId: userId, UserId: userId,
LastMessageAt: new Date().getTime(), LastMessageAt: new Date().getTime(),
messages: [], Messages: [],
} satisfies Contacts[0]; } satisfies Contacts[0];
return [newUser, ...prevContacts]; return [newUser, ...prevContacts];

View file

@ -33,6 +33,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const [redirect, setRedirect] = useState<string | null>(null); const [redirect, setRedirect] = useState<string | null>(null);
const [challenge, setChallenge] = useState<string | null>(null); const [challenge, setChallenge] = useState<string | null>(null);
const [appPublicKey, setAppPublicKey] = useState<string | null>(null); const [appPublicKey, setAppPublicKey] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [allowChildern, setAllowChildern] = useState(false); const [allowChildern, setAllowChildern] = useState(false);
const { deeplinks } = useDeeplinks(); const { deeplinks } = useDeeplinks();
@ -44,9 +45,9 @@ export default function Wrapper({ children }: { children: ReactNode }) {
// Get Data // Get Data
const user = await get(await load("user_id")); const user = await get(await load("user_id"));
const { const {
data: { content: appPublicKeyHash }, data: { Content: appPublicKeyHash },
} = await send("load_txt_record", { } = await send("LoadTxtRecord", {
path: "tauth." + identifier, Path: "tauth." + identifier,
}); });
const verifiedAppPublicKeyHash = await sha256Hex(appPublicKey); const verifiedAppPublicKeyHash = await sha256Hex(appPublicKey);
@ -61,7 +62,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
// Get Shared Secret // Get Shared Secret
const sharedSecret = await getSharedSecret( const sharedSecret = await getSharedSecret(
await load("private_key"), await load("mtp_keyring"),
user.PublicKey, user.PublicKey,
appPublicKey, appPublicKey,
).catch((err) => { ).catch((err) => {
@ -86,12 +87,13 @@ export default function Wrapper({ children }: { children: ReactNode }) {
finalUrl.searchParams.set("userId", String(await load("user_id"))); finalUrl.searchParams.set("userId", String(await load("user_id")));
finalUrl.searchParams.set("challenge", solvedChallenge); finalUrl.searchParams.set("challenge", solvedChallenge);
finalUrl.searchParams.set("originalChallenge", challenge); finalUrl.searchParams.set("originalChallenge", challenge);
finalUrl.searchParams.set(
const session = new Date().getTime(); "sessionId",
finalUrl.searchParams.set("sessionId", String(session)); String(sessionId || new Date().getTime()),
);
// Save Session // Save Session
await send("create_app", { await send("CreateApp", {
AppPublicKey: appPublicKey, AppPublicKey: appPublicKey,
AppIdentifier: identifier, AppIdentifier: identifier,
}); });
@ -105,12 +107,13 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setRedirect(null); setRedirect(null);
setChallenge(null); setChallenge(null);
setAppPublicKey(null); setAppPublicKey(null);
setSessionId(null);
toast("success", "App authorized successfully"); toast("success", "App authorized successfully");
return; return;
} else { } else {
navigate({ navigate({
to: finalUrl.toString(), href: finalUrl.toString(),
}); });
return; return;
} }
@ -124,6 +127,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setRedirect(null); setRedirect(null);
setChallenge(null); setChallenge(null);
setAppPublicKey(null); setAppPublicKey(null);
setSessionId(null);
} }
}; };
@ -156,6 +160,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const redirect = params.get("redirect"); const redirect = params.get("redirect");
const challenge = params.get("challenge"); const challenge = params.get("challenge");
const appPublicKey = params.get("public_key"); const appPublicKey = params.get("public_key");
const urlSessionId = params.get("sessionId");
if (!identifier || !redirect) { if (!identifier || !redirect) {
setAllowChildern(true); setAllowChildern(true);
return; return;
@ -165,6 +170,10 @@ export default function Wrapper({ children }: { children: ReactNode }) {
if (!challenge) { if (!challenge) {
const newUrl = new URL(redirect); const newUrl = new URL(redirect);
newUrl.searchParams.set("userId", String(userId)); newUrl.searchParams.set("userId", String(userId));
newUrl.searchParams.set(
"sessionId",
String(urlSessionId || new Date().getTime()),
);
window.location.href = newUrl.toString(); window.location.href = newUrl.toString();
return; return;
} }
@ -181,6 +190,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setRedirect(redirect); setRedirect(redirect);
setChallenge(hexToBase64(challenge || "")); setChallenge(hexToBase64(challenge || ""));
setAppPublicKey(appPublicKey); setAppPublicKey(appPublicKey);
setSessionId(urlSessionId);
setDialogOpen(true); setDialogOpen(true);
}); });
}, [searchStr, load]); }, [searchStr, load]);
@ -192,12 +202,14 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const identifier = url.searchParams.get("identifier"); const identifier = url.searchParams.get("identifier");
const redirect = url.searchParams.get("redirect"); const redirect = url.searchParams.get("redirect");
const appPublicKey = url.searchParams.get("public_key"); const appPublicKey = url.searchParams.get("public_key");
const urlSessionId = url.searchParams.get("sessionId");
if (identifier && redirect && appPublicKey) { if (identifier && redirect && appPublicKey) {
setIdentifier(identifier); setIdentifier(identifier);
setRedirect(redirect); setRedirect(redirect);
setChallenge(hexToBase64(url.searchParams.get("challenge") || "")); setChallenge(hexToBase64(url.searchParams.get("challenge") || ""));
setAppPublicKey(appPublicKey); setAppPublicKey(appPublicKey);
setSessionId(urlSessionId);
setDialogOpen(true); setDialogOpen(true);
} }
} }
@ -215,6 +227,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setRedirect(null); setRedirect(null);
setChallenge(null); setChallenge(null);
setAppPublicKey(null); setAppPublicKey(null);
setSessionId(null);
} }
}} }}
> >
@ -251,6 +264,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setRedirect(null); setRedirect(null);
setChallenge(null); setChallenge(null);
setAppPublicKey(null); setAppPublicKey(null);
setSessionId(null);
}} }}
> >
Deny Deny

View file

@ -4,7 +4,7 @@ import { useMTP } from "@tensamin/mtp";
import { mtp as schemas } from "@tensamin/shared/data"; import { mtp as schemas } from "@tensamin/shared/data";
import type z from "zod"; import type z from "zod";
export type User = z.infer<typeof schemas.get_user_data.response>; export type User = z.infer<typeof schemas.GetUserData.response>;
interface contextValue { interface contextValue {
get(userId: number): Promise<User>; get(userId: number): Promise<User>;
@ -47,11 +47,11 @@ export default function UserProvider(props: { children: React.ReactNode }) {
} }
const request = (async () => { const request = (async () => {
const userData = await send("get_user_data", { UserId: userId }); const userData = await send("GetUserData", { UserId: userId });
const user = { const user = {
...userData.data, ...userData.data,
avatar: userData.data.avatar avatar: userData.data.Avatar
? `data:image/webp;base64,${atob(userData.data.avatar)}` ? `data:image/webp;base64,${atob(userData.data.Avatar)}`
: undefined, : undefined,
}; };

16
pnpm-lock.yaml generated
View file

@ -6,7 +6,7 @@ settings:
overrides: overrides:
'@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz '@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz
importers: importers:
@ -16,8 +16,8 @@ importers:
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3) version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
mtp: mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@ -661,8 +661,8 @@ importers:
specifier: ^1.14.0 specifier: ^1.14.0
version: 1.23.0(react@19.2.7) version: 1.23.0(react@19.2.7)
mtp: mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.7 version: 19.2.7
@ -3752,8 +3752,8 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz: mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz:
resolution: {integrity: sha512-ixykS56llG6Azoxn4ff2v83LlrkCsd/s5wXmEUsxGtM63v8cYvX/HXh1AmWfJeaL0QZiGw1QyyOC0vplXZjwVg==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz} resolution: {integrity: sha512-jv3tONmaEMVyoX3HyQhZfkt46MkoaZAkoy3B9HjFUOh6ff42yutAKgt/WQgX9MP5AyFd8oVlvTgMpaOGk7046A==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz}
version: 0.1.0 version: 0.1.0
nanoid@3.3.15: nanoid@3.3.15:
@ -7879,7 +7879,7 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz: {} mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz: {}
nanoid@3.3.15: {} nanoid@3.3.15: {}

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true esbuild: true
overrides: overrides:
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz" "@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-2e7c0b4/mtp-0.1.0.tgz" mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/mtp-0.1.0.tgz"