(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 { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context";
@ -83,25 +83,18 @@ function parseTuFileContent(rawFileContent: string): {
return { userId, privateKey, domain };
}
/**
* Renders the login form for file upload and manual credential login.
* @returns Login form JSX.
*/
export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage();
/**
* Handles uploaded .tu files and stores resolved credentials.
* @param event Change event from the hidden file input.
* @returns Promise that resolves when processing has finished.
*/
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
// Process dropped files
const processDroppedFile = React.useCallback(
async (file: globalThis.File): Promise<void> => {
try {
const file = event.currentTarget.files?.[0];
if (!file) {
throw new Error("No file selected");
if (!file.name.endsWith(".tu")) {
toast("error", "Please upload a .tu file");
return;
}
const raw = await file.text();
@ -123,13 +116,94 @@ export default function Form() {
[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.
* @param event Form submit event.
* @returns Promise that resolves after login processing.
*/
const handleCredentialsSubmit = React.useCallback(
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@ -191,7 +265,7 @@ export default function Form() {
);
return (
<div className="flex md:flex-row flex-col gap-15">
<div className="relative flex md:flex-row flex-col gap-15">
{isTauri() ? (
<>
<QrCodeScanner
@ -199,24 +273,27 @@ export default function Form() {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
} else {
}
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await save("session_id", Date.now());
await save("user_id", userId);
await save("private_key", privateKey);
if (domain) {
await save("ttp_url", `https://${domain}/`);
}
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
@ -226,10 +303,30 @@ export default function Form() {
) : (
<div
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} />
<p className="text-md">Select .tu file</p>
<File
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>
)}
<input

View file

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

View file

@ -6,6 +6,20 @@
@source "./**/*.{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,
body,
#root {

View file

@ -1,12 +1,30 @@
import { Switch } from "@/features/settings/components";
import { Kbd } from "@tensamin/ui";
export default function Page() {
return (
<div>
<div className="flex flex-col gap-2">
<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"
/>
<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>
);
}

View file

@ -35,6 +35,7 @@ function MessageComponent({
return (
<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" : ""}`}
>
<ContextMenu>

View file

@ -438,7 +438,7 @@ export default function Screen() {
className="min-h-0 flex-1 overflow-y-auto"
style={{
overflowAnchor: "none",
paddingBottom: "8px",
paddingBottom: "22px",
}}
onScroll={handleContainerScroll}
>
@ -461,7 +461,6 @@ export default function Screen() {
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0",
}}
>
<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 }) {
const { subscribePush } = useTTP();
const { subscribePush, send } = useTTP();
const { load } = useStorage();
const { get } = useUser();
const { decryptText, getSharedSecret } = useCrypto();
@ -49,7 +49,32 @@ export default function Provider(props: { children: React.ReactNode }) {
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) {
addLiveMessage({
...message,
@ -60,7 +85,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return;
}
// add notification symbol to conversation cards
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(sender_id);
@ -91,6 +116,7 @@ export default function Provider(props: { children: React.ReactNode }) {
addLiveMessage,
userId,
moveUserIdToTop,
send,
]);
return (

View file

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

View file

@ -28,6 +28,21 @@ const settings = {
type: "boolean",
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: {
@ -35,6 +50,7 @@ const settings = {
},
} as const satisfies SettingsSchema;
// Assemble storage defaults
export default settings;
type BooleanSettingNames<T extends SettingsSchema> = {