diff --git a/convex/chats.ts b/convex/chats.ts index de3567b0..c81f40aa 100644 --- a/convex/chats.ts +++ b/convex/chats.ts @@ -79,6 +79,7 @@ export const initialConvexSetup = mutation({ username: identity.nickname, clerkId: identity.tokenIdentifier, firstName: identity.givenName, + email: identity.email, lastName: identity.familyName, }) .get(); diff --git a/convex/messages.ts b/convex/messages.ts index 7ebfcbca..b2f1fd0c 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -39,6 +39,56 @@ export const getMessages = query({ }, }); +export const createDeleteRequest = mutation({ + args: { chatId: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + + if (identity === null) { + console.error("Unauthenticated call to mutation"); + return null; + } + + const convexUser = await ctx + .table("users") + .get("clerkId", identity.tokenIdentifier); + + const parsedChatId = ctx.table("privateChats").normalizeId(args.chatId); + + if (!parsedChatId) { + throw new ConvexError("chatId was invalid"); + } + + if (!convexUser) { + throw new ConvexError( + "Mismatch between Clerk and Convex. This is an error by us.", + ); + } + + const usersInChat = await ctx + .table("privateChats") + .getX(parsedChatId) + .edge("users"); + + if ( + !usersInChat.some((user) => user.clerkId === identity.tokenIdentifier) + ) { + throw new ConvexError( + "UNAUTHORIZED REQUEST: User tried to send a message in a chat in which he is not in.", + ); + } + + await ctx.table("messages").insert({ + userId: convexUser._id, + privateChatId: parsedChatId, + content: "", + type: "request", + deleted: false, + readBy: [convexUser._id], + }); + }, +}); + export const createMessage = mutation({ args: { chatId: v.string(), content: v.string() }, handler: async (ctx, args) => { @@ -84,12 +134,62 @@ export const createMessage = mutation({ userId: convexUser._id, privateChatId: parsedChatId, content: args.content.trim(), + type: "message", deleted: false, readBy: [convexUser._id], }); }, }); +export const deleteAllMessagesInChat = mutation({ + args: { chatId: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + + if (identity === null) { + console.error("Unauthenticated call to mutation"); + return null; + } + + const parsedChatId = ctx.table("privateChats").normalizeId(args.chatId); + + if (!parsedChatId) { + throw new ConvexError("chatId was invalid"); + } + + const chat = ctx.table("privateChats").getX(parsedChatId); + const messagesInChat = await chat.edge("messages"); + + for (const message of messagesInChat) { + await message.delete(); + } + }, +}); + +export const rejectRequest = mutation({ + args: { messageId: v.string(), chatId: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + + if (identity === null) { + console.error("Unauthenticated call to mutation"); + return null; + } + + const parsedMessageId = ctx.table("messages").normalizeId(args.messageId); + + if (!parsedMessageId) { + throw new ConvexError("chatId was invalid"); + } + + const message = await ctx.table("messages").getX(parsedMessageId); + + await message.patch({ + type: "rejected", + }); + }, +}); + export const deleteMessage = mutation({ args: { messageId: v.string(), chatId: v.string() }, handler: async (ctx, args) => { @@ -145,6 +245,12 @@ export const markMessageRead = mutation({ ); } + const message = await ctx.table("messages").get(args.messageId); + + if (!message) { + return null; + } + await ctx .table("messages") .getX(args.messageId) @@ -153,5 +259,7 @@ export const markMessageRead = mutation({ add: [convexUser._id], }, }); + + return { success: true }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index dfa02208..7afe133b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -11,6 +11,7 @@ const schema = defineEntSchema({ .field("clerkId", v.string(), { unique: true }) .field("username", v.string(), { unique: true }) .field("firstName", v.optional(v.string())) + .field("email", v.optional(v.string())) .field("lastName", v.optional(v.string())) .edges("privateChats") .edges("messages", { ref: true }) @@ -22,6 +23,7 @@ const schema = defineEntSchema({ messages: defineEnt({}) .field("content", v.string()) + .field("type", v.string(), { default: "message" }) .field("deleted", v.boolean(), { default: false }) .edge("privateChat") .edge("user") diff --git a/convex/users.ts b/convex/users.ts index 756e7a14..121be44b 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,5 +1,5 @@ -import { query } from "./lib/functions"; -import { ConvexError } from "convex/values"; +import { mutation, query } from "./lib/functions"; +import { v } from "convex/values"; export const getUserData = query({ handler: async (ctx) => { @@ -13,3 +13,41 @@ export const getUserData = query({ return ctx.table("users").getX("clerkId", identity.tokenIdentifier); }, }); + +export const updateUserData = mutation({ + args: { + data: v.object({ + firstName: v.optional(v.string()), + lastName: v.optional(v.string()), + email: v.optional(v.string()), + }), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + + if (identity === null) { + console.error("Unauthenticated call to mutation"); + return null; + } + + const user = ctx.table("users").getX("clerkId", identity.tokenIdentifier); + + if (args.data.email) { + await user.patch({ + email: args.data.email, + }); + } + + if (args.data.lastName) { + await user.patch({ + lastName: args.data.lastName, + }); + } + + if (args.data.firstName) { + await user.patch({ + firstName: args.data.firstName, + }); + } + }, +}); diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx index 9fb5c42b..e6e3946b 100644 --- a/src/app/(auth)/sign-up/signup-form.tsx +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -16,7 +16,7 @@ import { } from "~/components/ui/form"; import { Input } from "~/components/ui/input"; import React, { useEffect } from "react"; -import { formSchema } from "~/lib/validators"; +import { formSchemaSignUp } from "~/lib/validators"; import { useSignIn } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import { cn } from "~/lib/utils"; @@ -38,8 +38,8 @@ export function SignUpForm() { const { isLoading, isAuthenticated } = useConvexAuth(); const router = useRouter(); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(formSchemaSignUp), defaultValues: { username: "", usernameId: "", @@ -62,7 +62,7 @@ export function SignUpForm() { } }, [initialConvexSetup, isAuthenticated, router, signUpComplete]); - async function onSubmit(values: z.infer) { + async function onSubmit(values: z.infer) { if (isAuthenticated || isLoading) { // TODO: Make a toast or something to tell the user has to sign out first return; @@ -89,6 +89,15 @@ export function SignUpForm() { return; } + if (parsedResponseBody.data?.statusText === "email_is_taken") { + form.setError("email", { + message: "Email is already taken. Please choose another.", + }); + + setFormIsLoading(false); + return; + } + if (parsedResponseBody.data?.statusText === "form_password_pwned") { form.setError("password", { message: @@ -244,6 +253,23 @@ export function SignUpForm() { > This is optional, so you can stay anonymous. + ( + + Email + + + + + This is optional, but if you forgot your password, we can send + you an email. + + + + )} + /> async () => { + await createDeleteRequest({ chatId }); + }; + const [menuActive, setMenuActive] = useState(false); const menuClick = () => { @@ -234,7 +242,10 @@ export default function Page({ params }: { params: { chatId: string } }) { className="relative flex flex-col" > - chatId: {params.chatId} + +

chatId: {params.chatId}

devMode$.set(false)}>Disable dev mode
diff --git a/src/app/(internal-sites)/profile/chats/page.tsx b/src/app/(internal-sites)/profile/chats/page.tsx new file mode 100644 index 00000000..f4c884ba --- /dev/null +++ b/src/app/(internal-sites)/profile/chats/page.tsx @@ -0,0 +1,5 @@ +const ChatsPage = () => { + return
Chats
; +}; + +export default ChatsPage; diff --git a/src/app/(internal-sites)/profile/notification/page.tsx b/src/app/(internal-sites)/profile/notification/page.tsx new file mode 100644 index 00000000..03d71219 --- /dev/null +++ b/src/app/(internal-sites)/profile/notification/page.tsx @@ -0,0 +1,5 @@ +const NotificationPage = () => { + return
Notification
; +}; + +export default NotificationPage; diff --git a/src/app/(internal-sites)/profile/page.tsx b/src/app/(internal-sites)/profile/page.tsx index 4ba8b616..4484f2b9 100644 --- a/src/app/(internal-sites)/profile/page.tsx +++ b/src/app/(internal-sites)/profile/page.tsx @@ -1,73 +1,114 @@ +"use client"; import { Avatar, AvatarFallback } from "~/components/ui/avatar"; -import { Lock } from "lucide-react"; - +import {ArrowRight, Lock} from "lucide-react"; import { Bell } from "lucide-react"; import Link from "next/link"; import { SendHorizontal } from "lucide-react"; import { Settings } from "lucide-react"; import { UsersRound } from "lucide-react"; -import { currentUser } from "@clerk/nextjs/server"; +import { useUser } from "@clerk/nextjs"; +import {cn} from "~/lib/utils"; interface settingsCard { title: string; - icon: JSX.Element; + icon?: JSX.Element; } -const settings: settingsCard[] = [ - { title: "Account", icon: }, - { title: "Chats", icon: }, - { title: "Notification", icon: }, - { title: "Settings", icon: }, - { title: "Contributors", icon: }, -]; +export default function Profile() { + const clerkUser = useUser(); + const username = clerkUser.user ? clerkUser.user.username || "" : ""; -export default async function Profile() { - const user = await currentUser(); - const username = user?.username; + const settings: settingsCard[] = [ + { title: username }, + { title: "Settings", icon: }, + { title: "Notification", icon: }, + { title: "Privacy", icon: }, + { title: "Chats", icon: }, + { title: "Contributors", icon: }, + ]; return ( -
+
-

- Profile -

- -
-
- - - {username ? username.substring(0, 2).toUpperCase() : "Y"} - - -
-
- {user?.lastName && user.firstName ? ( -
- {user.firstName} {user?.lastName} /
{" "} - {user.username} -
- ) : ( -
{user?.username}
- )} +

Profile

+
+
+
+ {settings.map((item) => { + if (item.title == username) { + return ( +
+
+ {item.title.slice(0, 2).toUpperCase()} +
+

+ {item.title} +

+
+ ); + } + })} +
+
+ {settings.map((item) => { + if (item.title == username || item.title == "Contributors") { + return; + } + return ( + +
+
+ {item.icon} +
+

+ {item.title} +

+
+
+ +
+ + ); + })} +
+
+
+ {settings.map((item) => { + if (item.title == "Contributors") { + return ( + +
+
+ {item.icon} +
+

+ {item.title} +

+
+
+ +
+ + ); + } + })}
-
- {settings.map((item) => { - return ( - -

- {item.icon} -

-

{item.title}

- - ); - })} -
); } diff --git a/src/app/(internal-sites)/profile/privacy/page.tsx b/src/app/(internal-sites)/profile/privacy/page.tsx new file mode 100644 index 00000000..6e2c6197 --- /dev/null +++ b/src/app/(internal-sites)/profile/privacy/page.tsx @@ -0,0 +1,5 @@ +const PrivacyPage = () => { + return
Privacy
; +}; + +export default PrivacyPage; diff --git a/src/app/(internal-sites)/profile/settings/page.tsx b/src/app/(internal-sites)/profile/settings/page.tsx index ed5ae367..057bed0c 100644 --- a/src/app/(internal-sites)/profile/settings/page.tsx +++ b/src/app/(internal-sites)/profile/settings/page.tsx @@ -1,5 +1,517 @@ +"use client"; + +import { Input } from "~/components/ui/input"; +import { useUser } from "@clerk/nextjs"; +import { useEffect, useState } from "react"; +import { isClerkAPIResponseError } from "@clerk/shared"; +import { + ChevronLeft, + CircleCheck, + CircleX, + HardDriveUpload, + MailCheck, + MailX, +} from "lucide-react"; +import { z, ZodError } from "zod"; +import { useRouter } from "next/navigation"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Label } from "~/components/ui/label"; +import { Toaster } from "~/components/ui/sonner"; +import { toast } from "sonner"; +import { FormSchemaUserUpdate, formSchemaUserUpdate } from "~/lib/validators"; +import { useMutation } from "convex/react"; +import { api } from "../../../../../convex/_generated/api"; + +const SettingValidator = z.object({ + email: z.string().email(), + password: z + .string() + .min(8, { + message: "Password must be at least 8 characters.", + }) + .max(20, { + message: "Password must be at most 20 characters.", + }), + firstName: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(20, { + message: "Name must be at most 20 characters.", + }), + lastName: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(20, { + message: "Name must be at most 20 characters.", + }), +}); + +const signUpResponseSchema = z.object({ + message: z.string(), + statusText: z.string().optional(), +}); + const SettingsPage = () => { - return
Settings
; + const clerkUser = useUser(); + const [lastName, setLastName] = useState(clerkUser.user?.lastName || ""); + const [firstName, setFirstName] = useState(clerkUser.user?.firstName || ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [currentPasswordErrorMessage, setCurrentPasswordErrorMessage] = + useState(""); + const [newPasswordErrorMessage, setNewPasswordErrorMessage] = useState(""); + const [emailValue, setEmailValue] = useState( + clerkUser.user?.primaryEmailAddress?.emailAddress || "", + ); + const [emailError, setEmailError] = useState(false); + const [firstNameError, setFirstNameError] = useState(""); + const [lastNameError, setLastNameError] = useState(""); + + const updateConvexUserData = useMutation(api.users.updateUserData); + + async function test() { + const valuesObject: z.infer = { + email: emailValue, + firstName: firstName, + lastName: lastName, + }; + + const response = await fetch("/api/sign-up", { + method: "OPTIONS", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(valuesObject), + }); + + const responseBody = await response.text(); // Get the response body as text + if (!responseBody) { + return; + } + + const parsedResponseBody = signUpResponseSchema.safeParse( + JSON.parse(responseBody), + ); + + if (parsedResponseBody.data?.message) { + const parsedJson = JSON.parse(parsedResponseBody.data.message) as + | any[] + | { [key: string]: any }; + + parsedJson.forEach((error: any) => { + if (error.path[0] == "email") { + setEmailError(true); + } + if (error.path[0] == "firstName") { + setFirstNameError(error.message); + } + if (error.path[0] == "lastName") { + setLastNameError(error.message); + } + }); + } + } + + useEffect(() => { + if (clerkUser.user?.firstName) { + setFirstName(clerkUser.user.firstName); + } + + if (clerkUser.user?.lastName) { + setLastName(clerkUser.user.lastName); + } + + if (clerkUser.user?.emailAddresses.map((email) => email.emailAddress)) { + setEmailValue(clerkUser.user?.primaryEmailAddress?.emailAddress ?? ""); + } + }, [ + clerkUser.user?.firstName, + clerkUser.user?.lastName, + clerkUser.user?.emailAddresses, + ]); + + const router = useRouter(); + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmailValue(e.target.value); + + try { + SettingValidator.parse({ email: e.target.value }); + setEmailError(false); + } catch (error) { + setEmailError(true); + if (error instanceof ZodError) { + const errorFound = error.errors.find( + (error) => error.path[0] == "email", + ); + if (errorFound) { + setEmailError(true); + } else { + setEmailError(false); + } + } + } + }; + + const handleFirstNameChange = (e: React.ChangeEvent) => { + setFirstName(e.target.value); + + try { + SettingValidator.parse({ firstName: e.target.value }); + setFirstNameError(""); + } catch (error) { + if (error instanceof ZodError) { + const errorFound = error.errors.find( + (error) => error.path[0] == "firstName", + ); + if (errorFound) { + setFirstNameError(errorFound.message); + } else { + setFirstNameError(""); + } + } + } + }; + + const handleLastNameChange = (e: React.ChangeEvent) => { + setLastName(e.target.value); + + try { + SettingValidator.parse({ lastName: e.target.value }); + setLastNameError(""); + } catch (error) { + if (error instanceof ZodError) { + const errorFound = error.errors.find( + (error) => error.path[0] == "lastName", + ); + if (errorFound) { + setLastNameError(errorFound.message); + } else { + setLastNameError(""); + } + } + } + }; + + const handleCurrentPassword = (e: React.ChangeEvent) => { + setCurrentPassword(e.target.value); + setCurrentPasswordErrorMessage(""); + }; + + const handleNewPassword = (e: React.ChangeEvent) => { + setNewPassword(e.target.value); + setNewPasswordErrorMessage(""); + + try { + SettingValidator.parse({ password: e.target.value }); + setNewPasswordErrorMessage(""); + } catch (e) { + if (e instanceof ZodError) { + const errorFound = e.errors.find((e) => e.path[0] == "password"); + if (errorFound) { + setNewPasswordErrorMessage(errorFound.message); + } else { + setNewPasswordErrorMessage(""); + } + } + } + }; + + useEffect(() => { + if (emailValue != "" || firstName != "" || lastName != "") { + test(); + } + }, [handleLastNameChange, handleFirstNameChange, handleEmailChange]); + + async function checkPasswordAgainstClerkRules( + currentPassword: string, + newPassword: string, + ) { + try { + try { + SettingValidator.parse({ password: newPassword }); + setNewPasswordErrorMessage(""); + } catch (e) { + if (e instanceof ZodError) { + const errorFound = e.errors.find((e) => e.path[0] == "password"); + if (errorFound) { + setNewPasswordErrorMessage(errorFound.message); + return; + } else { + setNewPasswordErrorMessage(""); + } + } + } + + await clerkUser.user?.updatePassword({ + currentPassword: currentPassword, + newPassword: newPassword, + }); + setDialogOpen(false); + toast.success("Password changed successfully"); + + console.log("Password changed successfully"); + } catch (e) { + if (isClerkAPIResponseError(e)) { + if (e.errors.some((error) => error.code === "form_password_pwned")) { + setNewPasswordErrorMessage( + "Password has been found in an online data breach.", + ); + } + if ( + e.errors.some( + (error) => error.code === "form_password_validation_failed", + ) + ) { + setCurrentPasswordErrorMessage("Invalid Current Password"); + } + } + } + } + + const firstNameSuccess = + firstName != clerkUser.user?.firstName && + firstName.length != 0 && + firstNameError == ""; + const lastNameSuccess = + lastName != clerkUser.user?.lastName && + lastName.length != 0 && + lastNameError == ""; + const emailSuccess = + emailValue != clerkUser.user?.primaryEmailAddress?.emailAddress && + emailValue.length != 0 && + !emailError; + + const userDataHandler = async (data: FormSchemaUserUpdate) => { + await updateConvexUserData({ + data: data, + }); + }; + + const submitHandler = async () => { + const successList = []; + const userDataToUpdate: FormSchemaUserUpdate = {}; + if (firstNameSuccess) { + clerkUser.user?.update({ firstName: firstName }); + successList.push(" First Name"); + userDataToUpdate.firstName = firstName; + } + if (lastNameSuccess) { + clerkUser.user?.update({ lastName: lastName }); + successList.push(" Last Name"); + userDataToUpdate.lastName = lastName; + } + + if (emailSuccess) { + userDataToUpdate.email = emailValue; + setEmailValue(clerkUser.user?.primaryEmailAddress?.emailAddress || ""); + } + + await userDataHandler(userDataToUpdate); + toast.success( + successList.map((update) => { + return update; + }) + " updated successfully", + ); + }; + + return ( + <> + +
+

Settings

+ { + router.back(); + }} + /> +
+
+
+
+
+ + {firstName != "" ? ( + firstNameError == "" ? ( + + ) : ( + + ) + ) : ( + "" + )} +
+
+ {firstNameError != "" && firstName != "" ? ( +

{firstNameError}

+ ) : ( + "First Name" + )} +
+
+
+
+ + {lastName != "" ? ( + lastNameError == "" ? ( + + ) : ( + + ) + ) : ( + "" + )} +
+
+ {lastNameError != "" && lastName != "" ? ( +

{lastNameError}

+ ) : ( + "Last Name" + )} +
+
+
+
+ + {emailValue != "" ? ( + !emailError ? ( + + ) : ( + + ) + ) : ( + "" + )} +
+

+ If you forgott your password we can send you a Email +

+
+ setDialogOpen((prevState) => !prevState)} + > + +
+ +

Update Password

+
+
+ + + Change Password + + If you want to change your password, you can do it here. + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ {lastNameSuccess || + firstNameSuccess || + (emailValue.length != 0 && + !emailError && + emailValue.toString() != + clerkUser.user?.emailAddresses + .map((email) => email.emailAddress) + .toString()) || + "" ? ( +
+ +

Save Changes

+
+ ) : null} +
+
+ + ); }; export default SettingsPage; diff --git a/src/app/api/sign-up/route.ts b/src/app/api/sign-up/route.ts index 9129f55f..5ccfe5e0 100644 --- a/src/app/api/sign-up/route.ts +++ b/src/app/api/sign-up/route.ts @@ -1,11 +1,30 @@ -import { type FormSchema, formSchema } from "~/lib/validators"; +import { type FormSchemaSignUp, formSchemaSignUp } from "~/lib/validators"; import { clerkClient } from "@clerk/nextjs/server"; import { isClerkAPIResponseError } from "@clerk/shared"; +import { formSchemaUserUpdate, FormSchemaUserUpdate } from "~/lib/validators"; + +export async function OPTIONS(request: Request) { + const unparsedSignUpHeaders = (await request.json()) as FormSchemaUserUpdate; + const parsedSignUpHeaders = formSchemaUserUpdate.safeParse( + unparsedSignUpHeaders, + ); + if (!parsedSignUpHeaders.success) { + return Response.json( + { message: parsedSignUpHeaders.error.message }, + { status: 400 }, + ); + } else { + return Response.json( + { message: parsedSignUpHeaders.error }, + { status: 200 }, + ); + } +} // TODO: This probably deserves a rate limiter + a check for not creating a bunch of trash users to spam us. export async function POST(request: Request) { - const unparsedSignUpHeaders = (await request.json()) as FormSchema; - const parsedSignUpHeaders = formSchema.safeParse(unparsedSignUpHeaders); + const unparsedSignUpHeaders = (await request.json()) as FormSchemaSignUp; + const parsedSignUpHeaders = formSchemaSignUp.safeParse(unparsedSignUpHeaders); if (!parsedSignUpHeaders.success) { return Response.json( { message: parsedSignUpHeaders.error.message }, @@ -19,18 +38,35 @@ export async function POST(request: Request) { parsedSignUpHeaders.data.username + parsedSignUpHeaders.data.usernameId, firstName: parsedSignUpHeaders.data.firstName, lastName: parsedSignUpHeaders.data.lastName, + emailAddress: parsedSignUpHeaders.data.email + ? [parsedSignUpHeaders.data.email] + : undefined, password: parsedSignUpHeaders.data.password, }); } catch (e) { if (isClerkAPIResponseError(e)) { if (e.errors.some((error) => error.code === "form_identifier_exists")) { - return Response.json( - { - message: "Failed to create an account. Username already exists.", - statusText: "username_is_taken", - }, - { status: 400 }, - ); + if ( + e.errors.some((error) => error.meta?.paramName === "email_address") + ) { + return Response.json( + { + message: "Failed to create an account. Email already exists.", + statusText: "email_is_taken", + }, + { status: 400 }, + ); + } + + if (e.errors.some((error) => error.meta?.paramName === "username")) { + return Response.json( + { + message: "Failed to create an account. Username already exists.", + statusText: "username_is_taken", + }, + { status: 400 }, + ); + } } if (e.errors.some((error) => error.code === "form_password_pwned")) { return Response.json( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6e2627aa..ac6d2e94 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,7 @@ import ConvexClientProvider from "~/app/convex-client-provider"; const APP_NAME = "Chat.io"; const APP_DEFAULT_TITLE = "Chat.io"; -const APP_TITLE_TEMPLATE = "%s - PWA App"; +const APP_TITLE_TEMPLATE = "%s - Chat.io"; const APP_DESCRIPTION = "Best PWA app in the world!"; export const metadata: Metadata = { diff --git a/src/components/chat-overview.tsx b/src/components/chat-overview.tsx index 0f64a6e2..5ed5f9cf 100644 --- a/src/components/chat-overview.tsx +++ b/src/components/chat-overview.tsx @@ -13,6 +13,7 @@ import { ArrowRight } from "lucide-react"; import Badge from "~/components/ui/badge"; import Link from "next/link"; import { cn } from "~/lib/utils"; +import type { Viewport } from "next"; type Chats = FunctionReturnType; diff --git a/src/components/message.tsx b/src/components/message.tsx index 44662fe5..6cb46fe1 100644 --- a/src/components/message.tsx +++ b/src/components/message.tsx @@ -1,7 +1,15 @@ import { useUser } from "@clerk/nextjs"; import { useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; -import { Ban, CopyCheck, Forward, Info, Trash2 } from "lucide-react"; +import { + Ban, + CircleCheck, + CircleX, + CopyCheck, + Forward, + Info, + Trash2, +} from "lucide-react"; import { FunctionReturnType } from "convex/server"; import { useInView } from "react-intersection-observer"; import { useEffect, useState } from "react"; @@ -12,6 +20,7 @@ import { useFloating } from "@floating-ui/react"; import { Toaster } from "~/components/ui/sonner"; import { toast } from "sonner"; import { Id } from "../../convex/_generated/dataModel"; +import { useQueryWithStatus } from "~/app/convex-client-provider"; dayjs.extend(relativeTime); @@ -118,27 +127,69 @@ export const Message = ({ const [isModalOpen, setIsModalOpen] = useState(false); const markRead = useMutation(api.messages.markMessageRead); + const deleteAllMessagesInChat = useMutation( + api.messages.deleteAllMessagesInChat, + ); + + const chatInfo = useQueryWithStatus(api.chats.getChatInfoFromId, { + chatId: message.privateChatId, + }); + + const rejectRequest = useMutation(api.messages.rejectRequest); + + const rejectRequestHandler = + (chatId: string, messageId: string) => async () => { + await rejectRequest({ chatId, messageId }); + }; + + const deleteAllMessagesinChat = (chatId: string) => async () => { + await deleteAllMessagesInChat({ chatId }); + }; + useEffect(() => { if (inView && message.sent) { markRead({ messageId: message._id }); } }, [inView, message._id, message.deleted, deleteMessage]); + const sentInfo = () => { + return ( + <> + Sent at {dayjs(message._creationTime).hour()}: + {dayjs(message._creationTime).minute() < 10 + ? "0" + dayjs(message._creationTime).minute() + : dayjs(message._creationTime).minute()} + {", "} + {dayjs(message._creationTime).date() < 10 + ? "0" + dayjs(message._creationTime).date() + : dayjs(message._creationTime).date()} + . + {dayjs(message._creationTime).month() + 1 < 10 + ? "0" + (dayjs(message._creationTime).month() + 1).toString() + : dayjs(message._creationTime).month() + 1} + .{dayjs(message._creationTime).year()} + + ); + }; + return ( <> - {isModalOpen && ( + {isModalOpen && message.type == "message" ? (
setIsModalOpen(!isModalOpen)} className="fixed inset-0 z-10 bg-black opacity-75" >
- )} + ) : null}
{message.from.username == clerkUser.user?.username ? (
{ @@ -161,6 +212,8 @@ export const Message = ({ "max-w-[66.6667%] cursor-default break-words rounded-sm bg-accent p-3", { "sticky z-50 opacity-100": message._id === selectedMessageId, + "my-2 max-w-[80%] border-2 border-secondary bg-primary": + message.type == "request" || message.type == "rejected", }, )} > @@ -170,11 +223,22 @@ export const Message = ({

This message was deleted

) : ( -
{message.content}
+
+ {message.type != "message" ? ( +
+ {message.type == "request" + ? "You`ve send a request" + : chatInfo.data?.otherUser[0]?.username + + " has rejected the request"} +
+ ) : ( +
{message.content}
+ )} +
)}
- {!message.deleted + {!message.deleted && message.type == "message" ? message.readBy ? message.readBy.map((user) => { if (user.username != clerkUser.user?.username) { @@ -192,7 +256,9 @@ export const Message = ({ : null : null}
- {message._id == selectedMessageId && isModalOpen ? ( + {message._id == selectedMessageId && + isModalOpen && + message.type == "message" ? (
{" "}
-

- Sent at {dayjs(message._creationTime).hour()}: - {dayjs(message._creationTime).minute() < 10 - ? "0" + dayjs(message._creationTime).minute() - : dayjs(message._creationTime).minute()} -

+

{sentInfo()}

@@ -243,7 +304,12 @@ export const Message = ({ ) : null}
) : ( -
+
{ @@ -266,6 +332,8 @@ export const Message = ({ "max-w-[66.6667%] cursor-default break-words rounded-sm bg-secondary p-3", { "sticky z-50 opacity-100": message._id == selectedMessageId, + "my-2 max-w-[80%] border-2 border-secondary bg-primary": + message.type == "request" || message.type == "rejected", }, )} > @@ -274,11 +342,47 @@ export const Message = ({

This message was deleted

+ ) : message.type != "message" ? ( +
+

+ {message.type == "request" + ? chatInfo.data?.otherUser[0]?.username + + " has send a delete Chat request" + : "You has rejected the request"} +

+
+ {message.type == "request" ? ( + <> + + {" "} + + ) : null} +
+
) : ( message.content )}
- {message._id == selectedMessageId && isModalOpen ? ( + {message._id == selectedMessageId && + isModalOpen && + message.type == "message" ? (
-

- Sent at {dayjs(message._creationTime).hour()}: - {dayjs(message._creationTime).minute() < 10 - ? "0" + dayjs(message._creationTime).minute() - : dayjs(message._creationTime).minute()} -

+

{sentInfo()}

diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 9d312c66..1a66a336 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( +type ToasterProps = React.ComponentProps; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); return ( { toastOptions={{ classNames: { toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-secondary group-[.toaster]:shadow-lg", description: "group-[.toast]:text-muted-foreground", actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", @@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => { }} {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 23161c79..5fb4d5b7 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const formSchema = z.object({ +export const formSchemaSignUp = z.object({ username: z .string() .min(7, { @@ -42,6 +42,7 @@ export const formSchema = z.object({ message: "Name must be at most 20 characters.", }) .optional(), + email: z.string().email().optional(), password: z .string() .min(8, { @@ -52,4 +53,28 @@ export const formSchema = z.object({ }), }); -export type FormSchema = z.infer; +export type FormSchemaSignUp = z.infer; + +export const formSchemaUserUpdate = z.object({ + firstName: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(20, { + message: "Name must be at most 20 characters.", + }) + .optional(), + lastName: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(20, { + message: "Name must be at most 20 characters.", + }) + .optional(), + email: z.string().email().optional(), +}); + +export type FormSchemaUserUpdate = z.infer; diff --git a/tailwind.config.ts b/tailwind.config.ts index 67de08d3..6d253a93 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -25,6 +25,7 @@ const config = { dark: "#005C8F", white: "#009BFA", }, + accept: "#00834C", border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))",