TTP -> MTP, A lot of other stuff #21

Merged
alois merged 81 commits from dev into main 2026-07-21 11:57:33 +03:00
30 changed files with 559 additions and 749 deletions
Showing only changes of commit 930663d495 - Show all commits

(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
Alois 2026-07-05 01:43:06 +02:00

View file

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

View file

@ -19,13 +19,13 @@ export function Basic({
extra?: React.ReactNode;
}) {
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">
<div className="relative shrink-0 overflow-visible">
<Avatar>
<AvatarImage src={user.avatar} />
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.display.slice(0, 2).toUpperCase()}
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<Tooltip>
@ -49,7 +49,7 @@ export function Basic({
</Tooltip>
</div>
<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 className="pr-1">{extra}</div>
</CardHeader>
@ -58,5 +58,5 @@ export function Basic({
}
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 () => {
try {
const ownId = await load("user_id");
const privateKey = await load("private_key");
const privateKey = await load("mtp_keyring");
const ownData = await get(ownId);
const secret = await getSharedSecret(
privateKey,
@ -61,17 +61,17 @@ export default function Profile({ user }: { user: User }) {
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Avatar className="size-10">
<AvatarImage src={user.avatar} />
<AvatarImage src={user.Avatar} />
<AvatarFallback className="text-lg">
{user.display.slice(0, 2).toUpperCase()}
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-lg font-semibold">{user.display}</p>
<p className="text-muted-foreground">{user.username}</p>
<p className="text-lg font-semibold">{user.Display}</p>
<p className="text-muted-foreground">{user.Username}</p>
</div>
</div>
<Text value={user.about || ""} />
<Text value={user.About || ""} />
<Button
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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,124 +1,83 @@
import { useStorage } from "@tensamin/storage/context";
import { Button, Input, Label } from "@tensamin/ui";
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() {
return (
<div>
<QrCodeLogin />
</div>
);
}
const { save, load } = useStorage();
const [draftMtpUrl, setDraftMtpUrl] = useState("");
const [currentMtpUrl, setCurrentMtpUrl] = useState("");
// QR Code Login
const generateQR = async (text: string): Promise<string> => {
try {
const url = await QRCode.toDataURL(text, {
errorCorrectionLevel: "H",
margin: 1,
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);
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState("");
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
useState("");
const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
useState("");
useEffect(() => {
load("private_key").then((value) => {
if (value) {
setPrivateKey(value);
}
load("omega_url").then((value) => {
setDraftMtpUrl(value);
setCurrentMtpUrl(value);
});
load("user_id").then((value) => {
if (value) {
setUserId(value);
}
load("forced_omikron_url").then((value) => {
setDraftForcedOmikronUrl(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]);
useEffect(() => {
if (userId && privateKey && connectionString !== null) {
generateQR(
`tensamin://tu::${userId}${connectionString}::${privateKey}`,
).then(setQrCodeBase64);
}
}, [userId, privateKey, connectionString]);
const [qrCodeVisible, setQrCodeVisible] = useState(false);
return (
<div className="flex flex-col gap-1 pl-2">
<p>Login QR Code</p>
<div className="relative rounded-lg w-50 border-3 aspect-square overflow-hidden">
{qrCodeBase64 && (
<img className="w-full h-full" src={qrCodeBase64} alt="QR Code" />
)}
{!qrCodeVisible && (
<>
<div className="absolute top-0 left-0 w-full h-full backdrop-blur-sm bg-background/50" />
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<AlertDialog>
<AlertDialogTrigger render={<Button>Show QR Code</Button>} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
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
compromised.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
render={
<Button onClick={() => setQrCodeVisible(true)}>
Show QR Code
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)}
<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>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,13 +11,13 @@ import type { RawMessages } from "../values";
*/
export async function getMessages(
send: BoundSendFn,
amount: number,
offset: number,
Amount: number,
Offset: number,
UserId: number,
): Promise<RawMessages> {
const messages = await send("messages_get", {
amount: amount,
offset: offset,
const messages = await send("MessagesGet", {
Amount,
Offset,
UserId,
});
@ -25,5 +25,5 @@ export async function getMessages(
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 ...");
const reference = addLiveMessage({
height: 0,
NotEncrypted: true,
SendTime: time,
content: currentValue,
Content: currentValue,
SentBySelf: true,
MessageState: "awaiting",
});
@ -94,9 +93,8 @@ export default function InputComponent({
log(3, "chat", "purple", "Content encrypted, sending message...");
send("message_send", {
height: 0,
content: encryptedContext,
send("MessageSend", {
Content: encryptedContext,
ReceiverId: userId,
SendTime: time,
}).catch((e) => {

View file

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

View file

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

View file

@ -22,12 +22,6 @@ type MessageChunk = {
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({
entry,
hasNextPage,
@ -80,8 +74,15 @@ function buildMessageChunks(
* @returns Chat screen JSX.
*/
export default function Screen() {
const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } =
useChat();
const {
getMessages,
liveMessages,
clearLiveMessages,
userId,
sharedSecret,
error,
errorDescription,
} = useChat();
const { get: getUser } = useUser();
const { load } = useStorage();
@ -251,16 +252,7 @@ export default function Screen() {
return FALLBACK_MESSAGE_HEIGHT;
}
const chunk = messageChunks[index];
if (!chunk) {
return FALLBACK_MESSAGE_HEIGHT;
}
return chunk.messages.reduce(
(total, message) => total + getEstimatedMessageHeight(message),
0,
);
return FALLBACK_MESSAGE_HEIGHT;
},
[messageChunks, shouldShowConversationStart],
);
@ -479,112 +471,122 @@ export default function Screen() {
if (!hasValidChatUser) {
return (
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
Invalid user
<div className="w-full h-full flex flex-col gap-2 items-center justify-center">
<p className="font-semibold text-xl">Invalid User</p>
</div>
);
}
return (
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<div
ref={scrollRef}
id="chat_container"
className="min-h-0 flex-1 overflow-y-auto"
style={{
overflowAnchor: "none",
paddingTop: "22px",
transform: "scaleY(-1)",
}}
onScroll={handleContainerScroll}
>
<div
className="relative w-full"
style={{ height: `${contentHeight}px` }}
>
{error !== "" && errorDescription !== "" ? (
<div className="w-full h-full flex flex-col gap-2 items-center justify-center">
<p className="font-semibold text-xl">{error}</p>
<p className="text-muted-foreground text-lg">{errorDescription}</p>
</div>
) : (
<>
<div
ref={topSentinelRef}
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
ref={scrollRef}
id="chat_container"
className="min-h-0 flex-1 overflow-y-auto"
style={{
overflowAnchor: "none",
paddingTop: "22px",
transform: "scaleY(-1)",
}}
onScroll={handleContainerScroll}
>
<div
className="relative w-full"
style={{ height: `${contentHeight}px` }}
>
<div
ref={topSentinelRef}
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) {
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>
);
}
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 className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
</div>
);
})}
</div>
</div>
<div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
</div>
</>
)}
</div>
);
}

View file

@ -1,7 +1,7 @@
import { z } from "zod";
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];

View file

@ -1,5 +1,5 @@
import * as React from "react";
import * as Comlink from "comlink";
import { createContext, useContext } from "react";
import { crypto } from "mtp";
type CryptoContextType = {
decrypt: (
@ -19,198 +19,26 @@ type CryptoContextType = {
) => Promise<string>;
};
type ApiRef = {
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 const context = createContext<CryptoContextType | undefined>(undefined);
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 }) {
const apiRef = React.useRef<ApiRef | null>(null);
const value = React.useMemo<CryptoContextType>(
() => ({
encrypt: async (secret, plaintext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
return await api.encrypt(secret, plaintext);
},
decrypt: async (secret, ciphertext) => {
const api = apiRef.current;
if (!api) throw new Error("API not initialized");
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,
);
},
}),
[],
return (
<context.Provider
value={{
decrypt: crypto.decrypt,
encrypt: crypto.encrypt,
decryptText: crypto.decryptText,
encryptText: crypto.encryptText,
getSharedSecret: crypto.getSharedSecret,
}}
>
{props.children}
</context.Provider>
);
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 {
const ctx = React.useContext(context);
const ctx = useContext(context);
if (!ctx) {
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 { MTPClient } from "mtp";
import { type z } from "zod";
import { ConnectionState } from "./values";
import createAsyncQueue, {
createQueuedFunc,
} from "@tensamin/shared/asyncQueue";
import { ConnectionState } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import { toast as sonnerToast } from "@tensamin/ui";
import { Loader2 } from "lucide-react";
@ -47,36 +45,6 @@ function base64ToUint8Array(b64: string) {
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<
T extends keyof Schemas & string = keyof Schemas & string,
> = {
@ -139,10 +107,8 @@ function validateResponse<T extends keyof Schemas & string>(
type: T,
message: { id?: number; type: string; data: unknown },
): ProtocolMessage<T> {
const appType = APP_TYPES[message.type] ?? message.type;
if (appType.startsWith("error")) {
return { ...message, type: appType } as ProtocolMessage<T>;
if (message.type.startsWith("Error")) {
return message as ProtocolMessage<T>;
}
const schema = schemas[type]?.response;
@ -159,7 +125,7 @@ function validateResponse<T extends keyof Schemas & string>(
return {
id: message.id,
type: appType,
type: message.type,
data: parsed.data,
} as ProtocolMessage<T>;
}
@ -192,7 +158,7 @@ export function Provider(props: {
// MTP url
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("mtp_url").then(setMtpUrl);
load("omega_url").then(setMtpUrl);
}, [load]);
// Validation override functions
@ -205,7 +171,7 @@ export function Provider(props: {
}
const message = await client.request(
WIRE_TYPES[type],
type,
(data ?? {}) as Record<string, unknown>,
options,
);
@ -220,7 +186,7 @@ export function Provider(props: {
return () => {};
}
return client.subscribe(WIRE_TYPES[type], (message) => {
return client.subscribe(type, (message) => {
handler(validateResponse(type, message));
});
}, []);
@ -231,8 +197,13 @@ export function Provider(props: {
return () => {};
}
const unsubscribers = PUSH_TYPES.map((type) =>
client.subscribe(WIRE_TYPES[type], (message) => {
const unsubscribers = [
"MessageLive",
"MessageState",
"CallInvite",
"ErrorNoIota",
].map((type) =>
client.subscribe(type, (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
useEffect(() => {
if (!connected || !identified) {
@ -268,7 +222,7 @@ export function Provider(props: {
const interval = setInterval(async () => {
try {
const originalNow = Date.now();
const data = await send("ping", { LastPing: originalNow });
const data = await send("Ping", { LastPing: originalNow });
setOwnPing(Date.now() - originalNow);
const remotePing = data.data.PingIota;
@ -286,6 +240,7 @@ export function Provider(props: {
}, [connected, identified, send]);
// Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
if (!mtpUrl) return;
@ -309,12 +264,10 @@ export function Provider(props: {
reconnectResetTimer = null;
};
let resolveConnection: (() => void) | null = null;
if (!props.blockConnection) {
sonnerToast.promise(
new Promise<void>((resolve) => {
resolveConnection = resolve;
resolveConnectionRef.current = resolve;
}),
{
id: "mtp-connection-toast",
@ -346,61 +299,64 @@ export function Provider(props: {
await MTPClient.init();
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(
`${mtpUrl}api/get/omikron/${await load("user_id")}`,
);
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
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,
});
resolveConnection?.();
cleanup();
return;
let url = null;
let omikronPublicKey = null;
if (forcedOmikronUrl && forcedOmikronPublicKey) {
url = forcedOmikronUrl;
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(
`${mtpUrl}api/get/omikron/${await load("user_id")}`,
);
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())),
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
)
throw new Error("Invalid Omikron data");
const url = `https://${omikronData.ip_address}:${omikronData.port}`;
if (!url || !omikronPublicKey)
throw new Error("Missing Omikron URL or Public Key");
log(2, "mtp", "green", "Connecting to: " + url);
const client = await MTPClient.create({
url,
storage: {
getItem: (key) => {
console.log(key);
return key;
},
removeItem: (key) => {
console.log(key);
},
setItem: console.log,
},
credentials: {
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",
pings: true,
logger: (event) => {
@ -410,12 +366,24 @@ export function Provider(props: {
);
}
if (event.type !== "Pong") {
if (event.type !== "Pong" && event.type !== "Ping") {
log(
2,
"mtp",
event.type === "state" ? "cyan" : "blue",
event.type === "state" ? event.data : event.type,
event.type === "state"
? "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,
);
}
@ -436,19 +404,22 @@ export function Provider(props: {
return;
}
const authPayload = new Promise<ProtocolMessage<"temp_cool_type">>(
(resolve, reject) => {
const unsubscribe = client.subscribe("TempCoolType", (message) => {
const authPayload = new Promise<
ProtocolMessage<"IdentificationResponse">
>((resolve, reject) => {
const unsubscribe = client.subscribe(
"IdentificationResponse",
(message) => {
try {
unsubscribe();
resolve(validateResponse("temp_cool_type", message));
resolve(validateResponse("IdentificationResponse", message));
} catch (authPayloadError) {
unsubscribe();
reject(authPayloadError);
}
});
},
);
},
);
});
clearReconnectTimer();
@ -467,12 +438,12 @@ export function Provider(props: {
if (disposed || clientRef.current !== client) return;
setFreshContacts(finalResponse.data.contacts);
setFreshCommunities(finalResponse.data.communities ?? []);
setFreshCalls(finalResponse.data.calls);
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
setIdentifying(false);
setIdentified(true);
resolveConnection?.();
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed) return;
cleanup();
@ -492,7 +463,7 @@ export function Provider(props: {
id: "mtp-connection-toast",
description:
connectError instanceof Error
? connectError.message
? connectError.message.split(":")[0]
: String(connectError ?? "Unknown error"),
icon: null,
duration: Infinity,
@ -558,6 +529,24 @@ export function Provider(props: {
};
}, [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
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
@ -587,10 +576,18 @@ export function Provider(props: {
}
}, [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 (
<MTPContext.Provider
value={{
send: createQueuedFunc(() => (contextReady ? send : null)),
send: sendQueued,
subscribe,
subscribePush,
readyState,

View file

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

View file

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

View file

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

View file

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

View file

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

16
pnpm-lock.yaml generated
View file

@ -6,7 +6,7 @@ settings:
overrides:
'@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:
@ -16,8 +16,8 @@ importers:
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)
mtp:
specifier: 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-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-fdc694b/mtp-0.1.0.tgz
sonner:
specifier: ^2.0.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
version: 1.23.0(react@19.2.7)
mtp:
specifier: 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-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-fdc694b/mtp-0.1.0.tgz
react:
specifier: ^19.2.0
version: 19.2.7
@ -3752,8 +3752,8 @@ packages:
ms@2.1.3:
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:
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}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-fdc694b/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
nanoid@3.3.15:
@ -7879,7 +7879,7 @@ snapshots:
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: {}

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true
overrides:
"@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"