(feat): add message states, tweaks chat ui
(feat): add drag and drop to login page
This commit is contained in:
parent
79beba44a4
commit
67b7926dab
9 changed files with 226 additions and 49 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue