From a9afe90b1593b2f36a99fc0a292c1be87e2295bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Thu, 28 Nov 2024 22:30:30 +1100 Subject: [PATCH 01/15] chore: remove unnecessary useEffect --- src/components/message.tsx | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/components/message.tsx b/src/components/message.tsx index a8f903d6..4359e658 100644 --- a/src/components/message.tsx +++ b/src/components/message.tsx @@ -128,22 +128,12 @@ export const Message = ({ threshold: 0.9, }); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkIfMobile = () => { - const userAgent = navigator.userAgent; - if (/android/i.test(userAgent)) { - setIsMobile(true); - } else if (/iPad|iPhone|iPod/.test(userAgent)) { - setIsMobile(true); - } else { - setIsMobile(false); - } - }; - - checkIfMobile(); - }, []); + const userAgent = navigator.userAgent; + const isMobile = /android/i.test(userAgent) + ? true + : /iPad|iPhone|iPod/.test(userAgent) + ? true + : false; const [messageOwner, setMessageOwner] = useState(null); const { refs, floatingStyles } = useFloating({ From 2488acbad7404d42a1235eb70afb53695b9c48b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sat, 30 Nov 2024 18:51:34 +1100 Subject: [PATCH 02/15] chore: cleaned up some code WIP --- convex/messages.ts | 62 +- convex/schema.ts | 7 + package.json | 1 + pnpm-lock.yaml | 19 + .../(internal-sites)/chats/[chatId]/page.tsx | 8 + src/components/message.tsx | 614 +++++++++--------- 6 files changed, 418 insertions(+), 293 deletions(-) diff --git a/convex/messages.ts b/convex/messages.ts index eabd4a50..e6ae0325 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -31,7 +31,7 @@ export const getMessages = query({ const [messages, requests] = await Promise.all([ chat.edge("messages").map(async (message) => { - const [from, readBy, replyTo] = await Promise.all([ + const [from, readBy, replyTo, reactions] = await Promise.all([ ctx.table("users").getX(message.userId), message.edge("readBy"), message.replyTo @@ -53,6 +53,7 @@ export const getMessages = query({ return null; }) : null, + message.edge("reactions").docs(), ]); return { @@ -62,6 +63,7 @@ export const getMessages = query({ from, readBy, replyTo, + reactions, sent: true, }; }), @@ -299,3 +301,61 @@ export const editMessage = mutation({ }); }, }); + +export const reactToMessage = mutation({ + args: { messageId: v.id("messages"), reaction: 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); + + if (!convexUser?._id) { + throw new ConvexError( + "Mismatch between Clerk and Convex. This is an error by us.", + ); + } + + // Check if reaction is an emoji + const emojiRegex = /^\p{Emoji}$/u; + if (!emojiRegex.test(args.reaction)) { + throw new ConvexError("Reaction must be a single emoji"); + } + + // Check if message exists + const messageId = ctx.table("messages").normalizeId(args.messageId); + + if (!messageId) { + throw new ConvexError("messageId was invalid"); + } + + // Check if user already reacted to this message + const existingReaction = await ctx + .table("reactions", "messageId", (q) => q.eq("messageId", messageId)) + .filter((q) => q.eq(q.field("userId"), convexUser._id)) + .first(); + + if (existingReaction) { + // Update existing reaction + await existingReaction.patch({ + emoji: args.reaction, + }); + return existingReaction; + } + + // Create new reaction if none exists + const reaction = await ctx.table("reactions").insert({ + emoji: args.reaction, + userId: convexUser._id, + messageId, + }); + + return reaction; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 8dcfe942..04591858 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -14,6 +14,7 @@ const schema = defineEntSchema({ .field("firstName", v.optional(v.string())) .field("email", v.optional(v.string())) .field("lastName", v.optional(v.string())) + .edges("reactions", { ref: true }) .edges("privateChats") .edges("messages", { ref: true }) .edges("clearRequests", { ref: true }) @@ -54,11 +55,17 @@ const schema = defineEntSchema({ .field("replyTo", v.optional(v.id("messages"))) .edge("privateChat") .edge("user") + .edges("reactions", { ref: true }) .edges("readBy", { to: "users", inverseField: "readMessages", table: "readMessages", }), + + reactions: defineEnt({}) + .field("emoji", v.string()) + .edge("user") + .edge("message"), }); export default schema; diff --git a/package.json b/package.json index 45c4cb7b..b5e33998 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "convex-ents": "^0.13.0", "convex-helpers": "^0.1.65", "dayjs": "^1.11.13", + "emoji-picker-react": "^4.12.0", "framer-motion": "^11.11.17", "geist": "^1.3.1", "import-in-the-middle": "^1.11.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8faba9ee..e811e0dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + emoji-picker-react: + specifier: ^4.12.0 + version: 4.12.0(react@19.0.0-rc-02c0e824-20241028) framer-motion: specifier: ^11.11.17 version: 11.12.0(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) @@ -2166,6 +2169,12 @@ packages: electron-to-chromium@1.5.65: resolution: {integrity: sha512-PWVzBjghx7/wop6n22vS2MLU8tKGd4Q91aCEGhG/TYmW6PP5OcSXcdnxTe1NNt0T66N8D6jxh4kC8UsdzOGaIw==} + emoji-picker-react@4.12.0: + resolution: {integrity: sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2406,6 +2415,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flairup@1.0.0: + resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -5899,6 +5911,11 @@ snapshots: electron-to-chromium@1.5.65: {} + emoji-picker-react@4.12.0(react@19.0.0-rc-02c0e824-20241028): + dependencies: + flairup: 1.0.0 + react: 19.0.0-rc-02c0e824-20241028 + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -6296,6 +6313,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flairup@1.0.0: {} + flat-cache@4.0.1: dependencies: flatted: 3.3.1 diff --git a/src/app/(internal-sites)/chats/[chatId]/page.tsx b/src/app/(internal-sites)/chats/[chatId]/page.tsx index cbb30970..4db9891e 100644 --- a/src/app/(internal-sites)/chats/[chatId]/page.tsx +++ b/src/app/(internal-sites)/chats/[chatId]/page.tsx @@ -189,6 +189,7 @@ export default function Page(props: { params: Promise<{ chatId: string }> }) { existingMessages && args.replyToId && replyTo?.type === "message" ? { ...replyTo, replyTo: undefined } : null, + reactions: [], }; localStore.setQuery(api.messages.getMessages, { chatId }, [ ...(Array.isArray(existingMessages) ? existingMessages : []), @@ -423,6 +424,12 @@ export default function Page(props: { params: Promise<{ chatId: string }> }) { return (
+ {selectedMessageId ? ( +
setSelectedMessageId(null)} + className="fixed inset-0 z-40 bg-black opacity-75" + >
+ ) : null} }) { setEditingMessageId={setEditingMessageId} setReplyToMessageId={setReplyToMessageId} message={message} + userInfo={userInfo.data} /> ))} diff --git a/src/components/message.tsx b/src/components/message.tsx index 4359e658..f92b9929 100644 --- a/src/components/message.tsx +++ b/src/components/message.tsx @@ -62,6 +62,7 @@ export const Message = ({ setSelectedMessageId, setEditingMessageId, setReplyToMessageId, + userInfo, }: { message: Message; selectedMessageId: string | null; @@ -72,6 +73,7 @@ export const Message = ({ setReplyToMessageId: React.Dispatch< React.SetStateAction | undefined> >; + userInfo: FunctionReturnType | undefined; }) => { const clerkUser = useUser(); @@ -116,6 +118,49 @@ export const Message = ({ } }); + const reactToMessage = useMutation( + api.messages.reactToMessage, + ).withOptimisticUpdate((localStore, args) => { + const messageId = args.messageId; + const emoji = args.reaction; + + if (!userInfo) return; + + const reaction = { + _id: crypto.randomUUID() as Id<"reactions">, + _creationTime: Date.now(), + messageId, + userId: userInfo._id, + emoji, + userInfo, + }; + + const existingMessages = localStore.getQuery(api.messages.getMessages, { + chatId: message.privateChatId, + }); + + if (existingMessages) { + localStore.setQuery( + api.messages.getMessages, + { chatId: message.privateChatId }, + existingMessages.map((message) => + message._id === messageId && message.type === "message" + ? { + ...message, + reactions: message.reactions?.find( + (reaction) => reaction.emoji === emoji, + ) + ? message.reactions.filter( + (reaction) => reaction.emoji !== emoji, + ) + : [...(message.reactions || []), reaction], + } + : message, + ), + ); + } + }); + const [isInBottomHalf, setIsInBottomHalf] = useState(null); const checkClickPosition = (e: React.MouseEvent) => { @@ -146,7 +191,6 @@ export const Message = ({ : "bottom-start", }); - const [isModalOpen, setIsModalOpen] = useState(false); const markRead = useMutation(api.messages.markMessageRead); const acceptClearRequest = useMutation(api.clearRequests.acceptClearRequest); @@ -210,319 +254,305 @@ export const Message = ({ const replyToMessageHandler = (messageId: Id<"messages">) => { setReplyToMessageId(messageId); - setIsModalOpen(!isModalOpen); + setSelectedMessageId(null); }; const chatContainerElement = document.getElementById("resizable-panel-chat"); return ( - <> - {isModalOpen && message.type == "message" ? ( +
+ {message.from.username == clerkUser.user?.username ? (
setIsModalOpen(!isModalOpen)} - className="fixed inset-0 z-10 bg-black opacity-75" - >
- ) : null} -
- {message.from.username == clerkUser.user?.username ? ( -
- - -
{ - e.preventDefault(); - if (message.type === "message" && message.deleted) return; - checkClickPosition(e); - setIsModalOpen(!isModalOpen); - setSelectedMessageId(message._id); - setMessageOwner(true); - }} - onClick={(e) => { - if (!isMobile) return; - if (message.type === "message" && message.deleted) return; - checkClickPosition(e); - setIsModalOpen(!isModalOpen); - setSelectedMessageId(message._id); - setMessageOwner(true); - }} - className={cn( - "max-w-[66.6667%] cursor-default break-words rounded-sm bg-accent p-3", - { - "sticky z-10 opacity-100": message._id === selectedMessageId, - "my-2 max-w-[80%] border-2 border-secondary bg-primary": - message.type == "pendingRequest" || - message.type == "rejectedRequest", - }, - )} - > - {message.type === "message" && message.deleted ? ( -
- -

This message was deleted

-
- ) : ( -
- {message.type != "message" ? ( -
- {message.type === "pendingRequest" ? ( - <> -

You've sent a request to clear the chat

-
- - Expires {getTimeRemaining()} -
- - ) : message.type === "expiredRequest" ? ( - "Your request to clear the chat has expired" - ) : ( - chatInfo.data?.otherUser[0]?.username + - " has rejected the request to clear the chat" - )} -
- ) : ( -
{message.content}
- )} -
- )} -
-
- {message.type == "message" && !message.deleted - ? message.readBy - ? message.readBy.map((user) => { - if (user.username != clerkUser.user?.username) { - return "Read"; - } else { - if (message.readBy.length === 1 && message.sent) { - return "Sent"; - } else if (message.readBy.length === 1) { - return "Sending"; - } else { - return null; - } - } - }) - : null - : null} -
- {chatContainerElement && - message._id == selectedMessageId && - isModalOpen && - message.type == "message" - ? // The reason for the creation of the portal is that we need the portal at a point where it is over EVERYTHING even the input etc. - createPortal( -
-
-
-
{ - void navigator.clipboard.writeText(message.content); - setIsModalOpen(!isModalOpen); - toast.success("Copied to clipboard"); - }} - > - -

Copy

-
- -
- -

Forward

-
- - {" "} -
- -

{sentInfo()}

-
-
-
-
, - chatContainerElement, - ) - : null} -
- ) : ( + ref={refs.setReference} + className={cn("my-1 flex w-full flex-col items-end", { + "mr-0 items-center": + message.type == "pendingRequest" || + message.type == "rejectedRequest", + })} + > + +
{ + e.preventDefault(); + if (message.type === "message" && message.deleted) return; + checkClickPosition(e); + setSelectedMessageId(message._id); + setMessageOwner(true); + }} + onClick={(e) => { + if (!isMobile) return; + if (message.type === "message" && message.deleted) return; + checkClickPosition(e); + setSelectedMessageId(message._id); + setMessageOwner(true); + }} + className={cn( + "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 == "pendingRequest" || + message.type == "rejectedRequest", + }, + )} > - - -
{ - e.preventDefault(); - if (message.type === "message" && message.deleted) return; - checkClickPosition(e); - setIsModalOpen(!isModalOpen); - setSelectedMessageId(message._id); - setMessageOwner(false); - }} - onClick={(e) => { - if (!isMobile) return; - if (message.type === "message" && message.deleted) return; - checkClickPosition(e); - setIsModalOpen(!isModalOpen); - setSelectedMessageId(message._id); - setMessageOwner(false); - }} - className={cn( - "max-w-[66.6667%] cursor-default break-words rounded-sm bg-secondary p-3", - { - "sticky z-10 opacity-100": message._id == selectedMessageId, - "my-2 max-w-[80%] border-2 border-secondary bg-primary": - message.type === "pendingRequest" || - message.type === "rejectedRequest", - }, - )} - > - {message.type === "message" && message.deleted ? ( -
- -

This message was deleted

-
- ) : message.type != "message" ? ( -
-
+ {message.type === "message" && message.deleted ? ( +
+ +

This message was deleted

+
+ ) : ( +
+ {message.type != "message" ? ( +
{message.type === "pendingRequest" ? ( <> - - {chatInfo.data?.otherUser[0]?.username} has sent a - request to clear the chat - +

You've sent a request to clear the chat

Expires {getTimeRemaining()}
) : message.type === "expiredRequest" ? ( - `The request of ${chatInfo.data?.otherUser[0]?.username + " to clear the chat"} has expired` + "Your request to clear the chat has expired" ) : ( - "You have rejected the request to clear the chat" + chatInfo.data?.otherUser[0]?.username + + " has rejected the request to clear the chat" )}
-
- {message.type === "pendingRequest" ? ( - <> - - {" "} - - ) : null} + ) : ( +
{message.content}
+ )} +
+ )} +
+
+ {message.type == "message" && !message.deleted + ? message.readBy + ? message.readBy.map((user) => { + if (user.username != clerkUser.user?.username) { + return "Read"; + } else { + if (message.readBy.length === 1 && message.sent) { + return "Sent"; + } else if (message.readBy.length === 1) { + return "Sending"; + } else { + return null; + } + } + }) + : null + : null} +
+ {chatContainerElement && + message._id == selectedMessageId && + message.type == "message" + ? // The reason for the creation of the portal is that we need the portal at a point where it is over EVERYTHING even the input etc. + createPortal( +
+
+
+
{ + void navigator.clipboard.writeText(message.content); + setSelectedMessageId(null); + toast.success("Copied to clipboard"); + }} + > + +

Copy

+
+ +
+ +

Forward

+
+ + {" "} +
+ +

{sentInfo()}

+
+
+
, + chatContainerElement, + ) + : null} +
+ ) : ( +
+ + +
{ + e.preventDefault(); + if (message.type === "message" && message.deleted) return; + checkClickPosition(e); + setSelectedMessageId(message._id); + setMessageOwner(false); + }} + onClick={(e) => { + if (!isMobile) return; + if (message.type === "message" && message.deleted) return; + checkClickPosition(e); + setSelectedMessageId(message._id); + setMessageOwner(false); + }} + className={cn( + "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 === "pendingRequest" || + message.type === "rejectedRequest", + }, + )} + > + {message.type === "message" && message.deleted ? ( +
+ +

This message was deleted

+
+ ) : message.type != "message" ? ( +
+
+ {message.type === "pendingRequest" ? ( + <> + + {chatInfo.data?.otherUser[0]?.username} has sent a + request to clear the chat + +
+ + Expires {getTimeRemaining()} +
+ + ) : message.type === "expiredRequest" ? ( + `The request of ${chatInfo.data?.otherUser[0]?.username + " to clear the chat"} has expired` + ) : ( + "You have rejected the request to clear the chat" + )}
- ) : ( - message.content - )} -
- {chatContainerElement && - message._id == selectedMessageId && - isModalOpen && - message.type == "message" - ? createPortal( -
-
-
-
{ - void navigator.clipboard.writeText(message.content); - setIsModalOpen(!isModalOpen); - toast.success("Copied to clipboard"); - }} - className="flex cursor-pointer p-2" - > - -

Copy

-
- -
- -

Forward

-
-
- -

{sentInfo()}

-
+
+ {message.type === "pendingRequest" ? ( + <> + + {" "} + + ) : null} +
+
+ ) : ( + message.content + )} +
+ {chatContainerElement && + message._id == selectedMessageId && + message.type == "message" + ? createPortal( +
+
+
+
{ + void navigator.clipboard.writeText(message.content); + setSelectedMessageId(null); + toast.success("Copied to clipboard"); + }} + className="flex cursor-pointer p-2" + > + +

Copy

+
+ +
+ +

Forward

+
+
+ +

{sentInfo()}

-
, - chatContainerElement, - ) - : null} -
- )} -
- +
+
, + chatContainerElement, + ) + : null} +
+ )} +
); }; From e95a282cd68ea38e939f9fa227e6de0a3089452d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sat, 30 Nov 2024 18:58:52 +1100 Subject: [PATCH 03/15] fix(styles): welcome to chat banner --- src/app/(internal-sites)/chats/[chatId]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(internal-sites)/chats/[chatId]/page.tsx b/src/app/(internal-sites)/chats/[chatId]/page.tsx index 4db9891e..600a5977 100644 --- a/src/app/(internal-sites)/chats/[chatId]/page.tsx +++ b/src/app/(internal-sites)/chats/[chatId]/page.tsx @@ -546,14 +546,14 @@ export default function Page(props: { params: Promise<{ chatId: string }> }) { {messages.data ? (
-
+
Let the Conversation Begin!
-

+

This is the beginning of an amazing chat. Share ideas, express yourself, and connect!

From 5492b86ff898265030a80a66f9927e14891af11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sat, 30 Nov 2024 22:38:07 +1100 Subject: [PATCH 04/15] chore: cleaned up the code --- .../(internal-sites)/chats/[chatId]/page.tsx | 4 +- src/components/message.tsx | 51 +++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/app/(internal-sites)/chats/[chatId]/page.tsx b/src/app/(internal-sites)/chats/[chatId]/page.tsx index 600a5977..771011ff 100644 --- a/src/app/(internal-sites)/chats/[chatId]/page.tsx +++ b/src/app/(internal-sites)/chats/[chatId]/page.tsx @@ -427,7 +427,7 @@ export default function Page(props: { params: Promise<{ chatId: string }> }) { {selectedMessageId ? (
setSelectedMessageId(null)} - className="fixed inset-0 z-40 bg-black opacity-75" + className="fixed inset-0 z-50 bg-black opacity-75" >
) : null} @@ -576,7 +576,7 @@ export default function Page(props: { params: Promise<{ chatId: string }> }) { ))} {!isNearBottom && messages.data.length > 0 && ( -
+