From 19712514909dd6eacddd7c2a752e8d5c373faf55 Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Wed, 22 Apr 2026 19:17:54 +0200 Subject: [PATCH] feat(chat): implement chat bubble, list, sidebar, topbar, and loading components - Added ChatBubble component for displaying individual chat messages with read status and timestamps. - Introduced ChatList component to manage and display a list of messages, including editing and deleting functionalities. - Created ChatSidebar for navigating between active chats and managing user interactions. - Developed ChatTopbar for displaying chat information and admin actions. - Implemented loading animation for messages with MessageLoading component. - Enhanced user experience with context menus for chat options and dialogs for editing and deleting messages. --- apps/db/migrations/46_uuidv7.up.sql | 15 + .../src/components/chat/chat-attachments.tsx | 195 ++++++ .../src/components/chat/chat-bottombar.tsx | 172 +++++ .../src/components/chat/chat-bubble.tsx | 135 ++++ .../src/components/chat/chat-list.tsx | 470 +++++++++++++ .../src/components/chat/chat-sidebar.tsx | 458 +++++++++++++ .../src/components/chat/chat-topbar.tsx | 202 ++++++ apps/infoalloggi/src/components/chat/chat.tsx | 50 ++ .../src/components/chat/message-loading.tsx | 46 ++ .../src/components/user_avatar.tsx | 130 ++-- apps/infoalloggi/src/hooks/chatHooks.ts | 200 ++---- apps/infoalloggi/src/hooks/usePassword.ts | 15 +- apps/infoalloggi/src/lib/utils.ts | 28 +- .../src/pages/area-riservata/admin/chats.tsx | 4 +- .../area-riservata/admin/chats/[chatId].tsx | 7 +- .../src/pages/area-riservata/chat.tsx | 2 +- .../infoalloggi/src/pages/test/chat-admin.tsx | 277 -------- apps/infoalloggi/src/pages/test/chat.tsx | 196 ------ apps/infoalloggi/src/server/api/root.ts | 4 - .../src/server/api/routers/catasto.ts | 11 - .../src/server/api/routers/chat.ts | 4 +- .../src/server/api/routers/chat_sse.ts | 634 ++++++++++++++---- .../src/server/api/routers/chat_sse2.ts | 246 ------- .../src/server/api/routers/messages_sse.ts | 294 -------- .../server/controllers/catasto.controller.ts | 16 - .../src/server/controllers/chat.controller.ts | 207 +----- .../server/controllers/invites.controller.ts | 4 +- .../src/server/services/messages.service.ts | 17 +- .../src/server/services/ordini.service.ts | 4 +- apps/infoalloggi/src/server/sse.ts | 150 ----- apps/infoalloggi/src/server/utils/sse_zod.ts | 61 -- .../infoalloggi/src/server/utils/zod_types.ts | 37 +- apps/infoalloggi/src/utils/kysely-helper.ts | 34 +- 33 files changed, 2452 insertions(+), 1873 deletions(-) create mode 100644 apps/db/migrations/46_uuidv7.up.sql create mode 100644 apps/infoalloggi/src/components/chat/chat-attachments.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat-bottombar.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat-bubble.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat-list.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat-sidebar.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat-topbar.tsx create mode 100644 apps/infoalloggi/src/components/chat/chat.tsx create mode 100644 apps/infoalloggi/src/components/chat/message-loading.tsx delete mode 100644 apps/infoalloggi/src/pages/test/chat-admin.tsx delete mode 100644 apps/infoalloggi/src/pages/test/chat.tsx delete mode 100644 apps/infoalloggi/src/server/api/routers/chat_sse2.ts delete mode 100644 apps/infoalloggi/src/server/api/routers/messages_sse.ts delete mode 100644 apps/infoalloggi/src/server/sse.ts delete mode 100644 apps/infoalloggi/src/server/utils/sse_zod.ts diff --git a/apps/db/migrations/46_uuidv7.up.sql b/apps/db/migrations/46_uuidv7.up.sql new file mode 100644 index 0000000..ff79578 --- /dev/null +++ b/apps/db/migrations/46_uuidv7.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +ALTER TABLE chats ALTER COLUMN chatid SET DEFAULT uuidv7(); +ALTER TABLE ordini ALTER COLUMN ordine_id SET DEFAULT uuidv7(); +ALTER TABLE potenziali_groups ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE potenziali ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE messages ALTER COLUMN messageid SET DEFAULT uuidv7(); +ALTER TABLE rinnovi ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE user_invites ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE users ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE servizio ALTER COLUMN servizio_id SET DEFAULT uuidv7(); +ALTER TABLE users_storage ALTER COLUMN user_storage_id SET DEFAULT uuidv7(); +ALTER TABLE users_anagrafica ALTER COLUMN idanagrafica SET DEFAULT uuidv7(); + +COMMIT; \ No newline at end of file diff --git a/apps/infoalloggi/src/components/chat/chat-attachments.tsx b/apps/infoalloggi/src/components/chat/chat-attachments.tsx new file mode 100644 index 0000000..9ba46a7 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-attachments.tsx @@ -0,0 +1,195 @@ +import { differenceInDays, differenceInMinutes, format } from "date-fns"; +import { Paperclip } from "lucide-react"; +import Link from "next/link"; +import { ExtIcon } from "~/components/area-riservata/allegati"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, + CredenzaTrigger, +} from "~/components/custom_ui/credenza"; +import { LoadingPage } from "~/components/loading"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { UploadComponent } from "~/components/upload_modal"; +import { cn } from "~/lib/utils"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; +import { api } from "~/utils/api"; + +type ChatAttachmentsProps = { + userData: Session; + chatUserData: ChatUserInfo; +}; +export const ChatAttachments = ({ + userData, + chatUserData, +}: ChatAttachmentsProps) => { + const { t } = useTranslation(); + const { + data: attachments, + isLoading: attachmentsLoading, + refetch, + } = api.storage.retrieveUserFileData.useQuery( + { userId: chatUserData.id }, + { refetchOnMount: true }, + ); + + return ( + o && refetch()}> + + + + + + Allegati + + Chat Attachments + + + + + +
+ {attachmentsLoading ? ( + + ) : ( + <> + {!attachments || attachments.length === 0 ? ( +
+

+ {t.allegati.nessun_allegato} +

+
+ ) : ( +
    +

    + {t.allegati.allegati_recenti} +

    + {attachments?.map((file, idx) => { + if (idx >= 3) return null; + + return ( +
  • + idx + }`} + > + console.log("touching")} + target="_blank" + > + + + + {TimeSince(new Date(file.uploadedAt))} + +
  • + ); + })} +
+ )} + + )} +
+ + {t.allegati.i_tuoi_allegati} + {attachments && attachments.length > 0 && ( + + {attachments.length} + + )} + +
+ + + + + +
+
+ ); +}; + +const TimeSince = (date: Date) => { + const minsSince = Math.abs(differenceInMinutes(date, new Date())); + const daysSince = Math.abs(differenceInDays(date, new Date())); + + const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120]; + + if (daysSince === 0) { + if (minsSince < 1) { + return "ora"; + } + + const closestToNow = relevantMinsIncrements.reduce((prev, curr) => { + return Math.abs(curr - minsSince) < Math.abs(prev - minsSince) + ? curr + : prev; + }); + + if (closestToNow === 60) { + return "~1 ora fa"; + } + + if (closestToNow === 120 && minsSince < 121) { + return "~2 ore fa"; + } + + return `~${closestToNow} min fa`; + } + + if (daysSince === 1) { + return "ieri"; + } + + if (daysSince < 30) { + return `${daysSince} giorni fa`; + } + + return format(date, "dd/MM/yyyy"); +}; diff --git a/apps/infoalloggi/src/components/chat/chat-bottombar.tsx b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx new file mode 100644 index 0000000..1a891cf --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react"; +import { + type ChangeEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { ChatAttachments } from "~/components/chat/chat-attachments"; +import { + type AutosizeTextAreaRef, + AutosizeTextarea, +} from "~/components/custom_ui/autoResizeTextArea"; +import { + EmojiPicker, + EmojiPickerContent, + EmojiPickerSearch, +} from "~/components/custom_ui/emoji-picker"; +import { Button } from "~/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { useThrottledIsTypingMutation } from "~/hooks/chatHooks"; +import { useMediaQuery } from "~/hooks/use-media-query"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; +import { api } from "~/utils/api"; + +type ChatBottombarProps = { + chatId: ChatsChatid; + userData: Session; + chatUserData: ChatUserInfo; +}; + +export default function ChatBottombar({ + chatId, + userData, + chatUserData, +}: ChatBottombarProps) { + const { locale } = useTranslation(); + const [message, setMessage] = useState(""); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + + const { mutate: addMutation } = api.chatSSE.sendMessage.useMutation({}); + + const handleThumbsUp = useCallback(() => { + addMutation({ + chatId, + message: "👍", + sender: userData.id, + }); + setMessage(""); + }, [chatId, addMutation, userData]); + + const handleSend = () => { + if (message.length !== 0) { + addMutation({ + chatId, + message: message.trim(), + sender: userData.id, + }); + + setMessage(""); + + if (inputRef.current) { + inputRef.current.textArea.focus(); + inputRef.current.textArea.style.height = "auto"; + } + } + }; + const isTypingMutation = useThrottledIsTypingMutation({ + chatId, + username: userData.username, + userId: userData.id, + }); + + useEffect(() => { + // update isTyping state + isTypingMutation(isFocused && message.trim().length > 0); + }, [isFocused, message, isTypingMutation]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.textArea.focus(); + } + }, []); + const [openEmoji, setOpenEmoji] = useState(false); + + const isMobile = useMediaQuery("(min-width: 426px)"); + return ( +
+
+ +
+
+ setIsFocused(false)} + onChange={(e: ChangeEvent) => { + setMessage(e.target.value); + }} + onFocus={() => setIsFocused(true)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + setMessage((prev) => `${prev}\n`); + } else { + handleSend(); + } + } + }} + placeholder="Aa" + ref={inputRef} + value={message} + /> + {isMobile && ( +
+ + + + + + { + setMessage(message + emoji); + // if (inputRef.current) { + // inputRef.current.textArea.focus(); + // } + }} + > + + + + + +
+ )} +
+ +
+ ); +} diff --git a/apps/infoalloggi/src/components/chat/chat-bubble.tsx b/apps/infoalloggi/src/components/chat/chat-bubble.tsx new file mode 100644 index 0000000..e61dad5 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-bubble.tsx @@ -0,0 +1,135 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, CheckCheck } from "lucide-react"; +import * as React from "react"; +import MessageLoading from "~/components/chat/message-loading"; +import { cn } from "~/lib/utils"; + +// ChatBubble +const chatBubbleVariant = cva( + "flex gap-2 max-w-[85%] items-end relative group ", + { + defaultVariants: { + layout: "default", + variant: "received", + }, + variants: { + layout: { + ai: "max-w-full w-full items-center", + default: "", + }, + variant: { + received: "self-start", + sent: "self-end flex-row-reverse", + }, + }, + }, +); + +interface ChatBubbleProps + extends React.HTMLAttributes, + VariantProps {} + +const ChatBubble = React.forwardRef( + ({ className, variant, layout, children, ...props }, ref) => ( +
+ {React.Children.map(children, (child) => + React.isValidElement(child) && typeof child.type !== "string" + ? React.cloneElement(child, { + layout, + variant, + } as Partial>) + : child, + )} +
+ ), +); +ChatBubble.displayName = "ChatBubble"; + +type ChatBubbleReadStatusProps = { + isread: boolean; +}; + +export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => { + if (isread) { + return ; + } + return ; +}; + +// ChatBubbleMessage +const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", { + defaultVariants: { + layout: "default", + variant: "received", + }, + variants: { + layout: { + ai: "border-t w-full rounded-none bg-transparent", + default: "", + }, + variant: { + received: + "bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg", + sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg", + }, + }, +}); + +interface ChatBubbleMessageProps + extends React.HTMLAttributes, + VariantProps { + isLoading?: boolean; +} + +const ChatBubbleMessage = React.forwardRef< + HTMLDivElement, + ChatBubbleMessageProps +>( + ( + { className, variant, layout, isLoading = false, children, ...props }, + ref, + ) => ( +
+ {isLoading ? ( +
+ +
+ ) : ( + children + )} +
+ ), +); +ChatBubbleMessage.displayName = "ChatBubbleMessage"; + +// ChatBubbleTimestamp +interface ChatBubbleTimestampProps + extends React.HTMLAttributes { + timestamp: string; +} + +const ChatBubbleTimestamp: React.FC = ({ + timestamp, + className, + ...props +}) => ( +
+ {timestamp} +
+); + +export { ChatBubble, ChatBubbleMessage, ChatBubbleTimestamp }; diff --git a/apps/infoalloggi/src/components/chat/chat-list.tsx b/apps/infoalloggi/src/components/chat/chat-list.tsx new file mode 100644 index 0000000..2fa2e8c --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-list.tsx @@ -0,0 +1,470 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Pencil, Trash2 } from "lucide-react"; +import { + type FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import toast from "react-hot-toast"; +import { + ChatBubble, + ChatBubbleMessage, + ChatBubbleReadStatus, + ChatBubbleTimestamp, +} from "~/components/chat/chat-bubble"; +import LoadingButton from "~/components/custom_ui/loading-button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button } from "~/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "~/components/ui/context-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import Input from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { getUserColorV2, UserAvatar } from "~/components/user_avatar"; +import type { LiveChat } from "~/hooks/chatHooks"; +import { cn, getInitials } from "~/lib/utils"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { MessagesMessageid } from "~/schemas/public/Messages"; +import type { UsersId } from "~/schemas/public/Users"; +import type { Message, TypingData } from "~/server/api/routers/chat_sse"; +import type { Session } from "~/server/api/trpc"; +import { api } from "~/utils/api"; + +type ChatListProps = { + chatId: ChatsChatid; + messages: Message[]; + userData: Session; + query: LiveChat["query"]; + typingStatus: TypingData; +}; + +export const ChatList = ({ + chatId, + messages, + userData, + query, + typingStatus, +}: ChatListProps) => { + const messagesContainerRef = useRef(null); + const [editModalOpen, setEditModalOpen] = useState(false); + const [msgSelected, setMsgSelected] = useState(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + const handleEditOpen = (status: boolean) => { + setEditModalOpen(status); + }; + const handleDeleteOpen = (status: boolean) => { + setDeleteModalOpen(status); + }; + + const { mutate: deleteMutation } = api.chatSSE.deleteMessage.useMutation({ + onError: () => { + toast.error("Error deleting message", { id: "deleteMessage" }); + }, + onMutate: () => { + toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" }); + }, + onSuccess: () => { + toast("Message deleted", { icon: "🗑️", id: "deleteMessage" }); + }, + }); + const { mutate: editMutation } = api.chatSSE.editMessage.useMutation({ + onError: () => { + toast.error("Error editing message", { id: "editMessage" }); + }, + onMutate: () => { + toast("Editing message...", { icon: "📝", id: "editMessage" }); + }, + onSuccess: () => { + toast("Message edited", { icon: "📝", id: "editMessage" }); + }, + }); + + const handleEditSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + + const raw_messageId = formData.get("message-id"); + const raw_message = formData.get("message-edit"); + + if (!raw_message || !raw_messageId) { + return; + } + const messageId = raw_messageId.toString() as MessagesMessageid; + const message = raw_message.toString(); + editMutation({ + chatId, + message, + messageId, + }); + setEditModalOpen(false); + }, + [editMutation], + ); + const [locked, setLocked] = useState(false); + + useEffect(() => { + if ( + messagesContainerRef.current && + !locked && + !query.isLoading && + !query.isFetching + ) { + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight; + } + setLocked(false); + }, [messages, typingStatus]); + + return ( +
+
+ + {query.hasNextPage && ( + { + setLocked(true); + await query.fetchNextPage(); + }} + variant="secondary" + > + {query.isFetchingNextPage + ? "Caricamento..." + : "Carica messaggi precedenti"} + + )} + + {messages.map((message, index) => { + const variant = getMessageVariant(message.sender, userData.id); + + const isNextSameSender = + index + 1 < messages.length && + messages[index + 1]?.sender === message.sender; + + const isPreviousSameSender = + index - 1 >= 0 && messages[index - 1]?.sender === message.sender; + + const isPrevSameDay = + index - 1 >= 0 && + // biome-ignore lint/style/noNonNullAssertion: + new Date(messages[index - 1]!.time).toDateString() === + new Date(message.time).toDateString(); + + const userColor = getUserColorV2( + `${getInitials(message.sender_nome)}${message.sender}`, + ); + + return ( + + {!isPrevSameDay && ( + + {new Date(message.time).toLocaleDateString("it", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + })} + + )} + + + {isNextSameSender ? ( +
+ ) : ( + + )} + + +
+ {!isPreviousSameSender && ( +

+ {message.sender_nome} +

+ )} + + + {message.message} + +
+ {userData.id === message.sender && ( + + )} + {message.time && ( + + )} +
+
+
+
+ + { + setMsgSelected(message); + handleDeleteOpen(true); + }} + > + + Cancella + + { + setMsgSelected(message); + handleEditOpen(true); + }} + > + + Modifica + + +
+
+
+ ); + })} + + +
+
+ + { + deleteMutation({ chatId, messageId }); + }} + msgDelete={msgSelected} + onOpenChange={handleDeleteOpen} + open={deleteModalOpen} + /> +
+ ); +}; + +const Typers = ({ + currentTypers, + messageLenght, + userId, +}: { + currentTypers: TypingData; + messageLenght: number; + userId: UsersId; +}) => { + return ( + <> + {currentTypers.map((typer) => { + if (typer.userId === userId) return null; + const variant = "received"; + + return ( + + + + + + + + ); + })} + + ); +}; + +const EditMessageDialog = ({ + open, + onOpenChange, + msgEdit, + handleEditSubmit, +}: { + open: boolean; + onOpenChange: (status: boolean) => void; + msgEdit: Message | null; + handleEditSubmit: (e: FormEvent) => void; +}) => { + const { t } = useTranslation(); + return ( + + + + {t.modifica} + Edit + + {msgEdit && ( +
+
+ + + +