(feat): version dump for testing #8

Merged
alois merged 4 commits from dev into main 2026-05-24 19:00:32 +03:00
9 changed files with 226 additions and 49 deletions
Showing only changes of commit 67b7926dab - Show all commits

(feat): add message states, tweaks chat ui
All checks were successful
/ build-web (push) Successful in 1m10s
/ build-desktop (push) Successful in 11m22s
/ build-mobile (push) Successful in 15m52s
/ release (push) Successful in 23s

(feat): add drag and drop to login page
Alois 2026-05-24 14:09:57 +02:00

View file

@ -1,4 +1,4 @@
import { Button } from "@tensamin/ui"; import { Button, cn } from "@tensamin/ui";
import { Input } from "@tensamin/ui"; import { Input } from "@tensamin/ui";
import { Label } from "@tensamin/ui"; import { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
@ -83,25 +83,18 @@ function parseTuFileContent(rawFileContent: string): {
return { userId, privateKey, domain }; return { userId, privateKey, domain };
} }
/**
* Renders the login form for file upload and manual credential login.
* @returns Login form JSX.
*/
export default function Form() { export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null); const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage(); const { save } = useStorage();
/** // Process dropped files
* Handles uploaded .tu files and stores resolved credentials. const processDroppedFile = React.useCallback(
* @param event Change event from the hidden file input. async (file: globalThis.File): Promise<void> => {
* @returns Promise that resolves when processing has finished.
*/
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
try { try {
const file = event.currentTarget.files?.[0]; if (!file.name.endsWith(".tu")) {
if (!file) { toast("error", "Please upload a .tu file");
throw new Error("No file selected"); return;
} }
const raw = await file.text(); const raw = await file.text();
@ -123,13 +116,94 @@ export default function Form() {
[save], [save],
); );
// Handle .tu files
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.currentTarget.files?.[0];
if (!file) {
toast("error", "No file selected");
return;
}
await processDroppedFile(file);
},
[processDroppedFile],
);
// Drag and drop listener
React.useEffect(() => {
let dragCounter = 0;
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter++;
if (event.dataTransfer?.items?.length) {
setIsDragging(true);
}
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter--;
if (dragCounter <= 0) {
setIsDragging(false);
}
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter = 0;
setIsDragging(false);
const file = event.dataTransfer?.files?.[0];
if (!file) {
toast("error", "No file dropped");
return;
}
await processDroppedFile(file);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}, [processDroppedFile]);
/** /**
* Handles username and private key login submission. * Handles username and private key login submission.
* @param event Form submit event. * @param event Form submit event.
* @returns Promise that resolves after login processing. * @returns Promise that resolves after login processing.
*/ */
const handleCredentialsSubmit = React.useCallback( const handleCredentialsSubmit = React.useCallback(
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => { async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@ -191,7 +265,7 @@ export default function Form() {
); );
return ( return (
<div className="flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauri() ? ( {isTauri() ? (
<> <>
<QrCodeScanner <QrCodeScanner
@ -199,23 +273,26 @@ export default function Form() {
if (!data.startsWith("tensamin://tu::")) { if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code"); toast("error", "Invalid QR code");
return; return;
} else { }
const decoded = data.replace("tensamin://tu::", "");
try { const decoded = data.replace("tensamin://tu::", "");
const { userId, privateKey, domain } =
parseTuFileContent(decoded); try {
await save("session_id", Date.now()); const { userId, privateKey, domain } =
await save("user_id", userId); parseTuFileContent(decoded);
await save("private_key", privateKey);
if (domain) { await save("session_id", Date.now());
await save("ttp_url", `https://${domain}/`); await save("user_id", userId);
} await save("private_key", privateKey);
location.href = "/";
} catch (error) { if (domain) {
log(0, "login", "red", error); await save("ttp_url", `https://${domain}/`);
toast("error", "Failed to parse QR code data");
} }
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
} }
}} }}
/> />
@ -226,10 +303,30 @@ export default function Form() {
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg" className={cn(
"flex flex-col gap-3 cursor-pointer w-55 aspect-square",
"border-3 items-center justify-center rounded-lg",
"transition-all duration-300 ease-in-out",
"border-input",
"bg-input/13 hover:bg-input/30",
isDragging ? "animate-wiggle" : "",
)}
> >
<File className="text-foreground" size={27} /> <File
<p className="text-md">Select .tu file</p> className={["transition-all duration-300 text-foreground"].join(
" ",
)}
size={27}
/>
<p
className={[
"text-md transition-all duration-300",
isDragging ? "font-medium" : "",
].join(" ")}
>
Select .tu file
</p>
</div> </div>
)} )}
<input <input

View file

@ -36,7 +36,7 @@ export function Switch({
label, label,
id, id,
}: { }: {
label: string; label: React.ReactNode;
id: keyof typeof settingsStorageDefaults & BooleanStorageKey; id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
}) { }) {
const { save, load } = useStorage(); const { save, load } = useStorage();

View file

@ -6,6 +6,20 @@
@source "./**/*.{ts,tsx}"; @source "./**/*.{ts,tsx}";
@source "../../../packages/**/src/**/*.{ts,tsx}"; @source "../../../packages/**/src/**/*.{ts,tsx}";
@theme {
--animate-wiggle: wiggle 0.5s ease-in-out infinite;
@keyframes wiggle {
0%,
100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
}
html, html,
body, body,
#root { #root {

View file

@ -1,12 +1,30 @@
import { Switch } from "@/features/settings/components"; import { Switch } from "@/features/settings/components";
import { Kbd } from "@tensamin/ui";
export default function Page() { export default function Page() {
return ( return (
<div> <div className="flex flex-col gap-2">
<Switch <Switch
label="Reverse Enter Key Behavior" label={
<p>
Change <Kbd>Enter</Kbd> behavior to <Kbd>Shift</Kbd> +{" "}
<Kbd>Enter</Kbd>
</p>
}
id="settings.reverse_enter_behavior" id="settings.reverse_enter_behavior"
/> />
<Switch
label="Enable read confirmations"
id="settings.read_confirmations"
/>
<Switch
label="Enable receive confirmations"
id="settings.receive_confirmations"
/>
<Switch
label="Sidebar message preview"
id="settings.show_start_of_last_message_in_sidebar"
/>
</div> </div>
); );
} }

View file

@ -35,6 +35,7 @@ function MessageComponent({
return ( return (
<div <div
// pt-3 is to get a gap between messages
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`} className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`}
> >
<ContextMenu> <ContextMenu>

View file

@ -438,7 +438,7 @@ export default function Screen() {
className="min-h-0 flex-1 overflow-y-auto" className="min-h-0 flex-1 overflow-y-auto"
style={{ style={{
overflowAnchor: "none", overflowAnchor: "none",
paddingBottom: "8px", paddingBottom: "22px",
}} }}
onScroll={handleContainerScroll} onScroll={handleContainerScroll}
> >
@ -461,7 +461,6 @@ export default function Screen() {
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start + verticalOffset}px)`, transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0",
}} }}
> >
<div className="w-full flex justify-start"> <div className="w-full flex justify-start">

View file

@ -23,7 +23,7 @@ function reduceDisplay(display: string) {
} }
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush } = useTTP(); const { subscribePush, send } = useTTP();
const { load } = useStorage(); const { load } = useStorage();
const { get } = useUser(); const { get } = useUser();
const { decryptText, getSharedSecret } = useCrypto(); const { decryptText, getSharedSecret } = useCrypto();
@ -49,7 +49,32 @@ export default function Provider(props: { children: React.ReactNode }) {
message.content, message.content,
); );
console.log(userId, sender_id, userId === sender_id); // Update message state
if (
(await load("settings.read_confirmations")) &&
userId === sender_id
) {
await send(
"message_state",
{
message_state: "read",
},
{
id: ttpMessage.id,
},
);
} else {
await send(
"message_state",
{
message_state: "received",
},
{
id: ttpMessage.id,
},
);
}
if (userId === sender_id) { if (userId === sender_id) {
addLiveMessage({ addLiveMessage({
...message, ...message,
@ -60,7 +85,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return; return;
} }
// add notification symbol to conversation cards // todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(sender_id); moveUserIdToTop(sender_id);
@ -91,6 +116,7 @@ export default function Provider(props: { children: React.ReactNode }) {
addLiveMessage, addLiveMessage,
userId, userId,
moveUserIdToTop, moveUserIdToTop,
send,
]); ]);
return ( return (

View file

@ -167,11 +167,17 @@ export const ttp = {
response: z.object({}), response: z.object({}),
}, },
message_state: { message_state: {
request: z.object({ request: z
chat_partner_id: z.number(), .object({
send_time: z.number(), chat_partner_id: z.number(),
message_state: message.shape.message_state, send_time: z.number(),
}), message_state: message.shape.message_state,
})
.or(
z.object({
message_state: message.shape.message_state,
}),
),
response: z.object({ response: z.object({
chat_partner_id: z.number(), chat_partner_id: z.number(),
message_state: message.shape.message_state, message_state: message.shape.message_state,

View file

@ -28,6 +28,21 @@ const settings = {
type: "boolean", type: "boolean",
default: false, default: false,
}, },
read_confirmations: {
display: "Enable Read Confirmations",
type: "boolean",
default: true,
},
receive_confirmations: {
display: "Enable Receive Confirmations",
type: "boolean",
default: true,
},
show_start_of_last_message_in_sidebar: {
display: "Show Start of Last Message in Sidebar",
type: "boolean",
default: true,
},
}, },
}, },
application: { application: {
@ -35,6 +50,7 @@ const settings = {
}, },
} as const satisfies SettingsSchema; } as const satisfies SettingsSchema;
// Assemble storage defaults
export default settings; export default settings;
type BooleanSettingNames<T extends SettingsSchema> = { type BooleanSettingNames<T extends SettingsSchema> = {