From 67b7926dabb318b66210ee92008cbace70d3624b Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 24 May 2026 14:09:57 +0200 Subject: [PATCH] (feat): add message states, tweaks chat ui (feat): add drag and drop to login page --- .../web/src/components/screens/login/form.tsx | 167 ++++++++++++++---- apps/web/src/features/settings/components.tsx | 2 +- apps/web/src/index.css | 14 ++ apps/web/src/routes/settings/chat.tsx | 22 ++- packages/chat/src/components/message.tsx | 1 + packages/chat/src/screen.tsx | 3 +- packages/notifications/src/context.tsx | 32 +++- packages/shared/src/data.ts | 16 +- packages/shared/src/settings.ts | 16 ++ 9 files changed, 225 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/screens/login/form.tsx b/apps/web/src/components/screens/login/form.tsx index c66d5e6..f275840 100644 --- a/apps/web/src/components/screens/login/form.tsx +++ b/apps/web/src/components/screens/login/form.tsx @@ -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(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): Promise => { + // Process dropped files + const processDroppedFile = React.useCallback( + async (file: globalThis.File): Promise => { 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): Promise => { + 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): Promise => { + async (event: React.FormEvent): Promise => { event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -191,7 +265,7 @@ export default function Form() { ); return ( -
+
{isTauri() ? ( <> @@ -226,10 +303,30 @@ export default function Form() { ) : (
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" : "", + )} > - -

Select .tu file

+ + +

+ Select .tu file +

)} +
+ Change Enter behavior to Shift +{" "} + Enter +

+ } id="settings.reverse_enter_behavior" /> + + +
); } diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 1bb0b43..18cf792 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -35,6 +35,7 @@ function MessageComponent({ return (
diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index fe69542..30344a5 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -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", }} >
diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 4e433a3..653aa07 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -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 ( diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 58851ce..2210192 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -167,11 +167,17 @@ export const ttp = { response: z.object({}), }, message_state: { - request: z.object({ - chat_partner_id: z.number(), - send_time: z.number(), - message_state: message.shape.message_state, - }), + 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, diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 0099f64..aacc162 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -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 = {