diff --git a/apps/db/migrations/47_messages.up.sql b/apps/db/migrations/47_messages.up.sql new file mode 100644 index 0000000..b5e2277 --- /dev/null +++ b/apps/db/migrations/47_messages.up.sql @@ -0,0 +1,12 @@ +-- Migration: 47_messages.up.sql + +UPDATE public.messages +SET message = '' +WHERE message IS NULL; + +ALTER TABLE IF EXISTS public.messages + ALTER COLUMN IF EXISTS message SET NOT NULL; + +ALTER TABLE IF EXISTS public.messages +ADD COLUMN IF NOT EXISTS reply_to_id UUID REFERENCES messages(messageid) ON DELETE SET NULL, +ADD COLUMN IF NOT EXISTS is_forwarded BOOLEAN NOT NULL DEFAULT FALSE; \ 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 index 9ba46a7..743427f 100644 --- a/apps/infoalloggi/src/components/chat/chat-attachments.tsx +++ b/apps/infoalloggi/src/components/chat/chat-attachments.tsx @@ -17,26 +17,19 @@ import { LoadingPage } from "~/components/loading"; import { Button, buttonVariants } from "~/components/ui/button"; import { UploadComponent } from "~/components/upload_modal"; import { cn } from "~/lib/utils"; +import { useChatContext } from "~/providers/ChatProvider"; 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) => { +export const ChatAttachments = () => { + const { session, userinfo } = useChatContext(); const { t } = useTranslation(); const { data: attachments, isLoading: attachmentsLoading, refetch, } = api.storage.retrieveUserFileData.useQuery( - { userId: chatUserData.id }, + { userId: userinfo.id }, { refetchOnMount: true }, ); @@ -65,10 +58,7 @@ export const ChatAttachments = ({ - +
{attachmentsLoading ? ( diff --git a/apps/infoalloggi/src/components/chat/chat-bottombar.tsx b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx index 1a891cf..bcfc922 100644 --- a/apps/infoalloggi/src/components/chat/chat-bottombar.tsx +++ b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx @@ -1,18 +1,13 @@ -"use client"; - -import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react"; import { - type ChangeEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; + ArrowDown, + SendHorizontal, + SmilePlusIcon, + ThumbsUp, + X, +} from "lucide-react"; +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; import { ChatAttachments } from "~/components/chat/chat-attachments"; -import { - type AutosizeTextAreaRef, - AutosizeTextarea, -} from "~/components/custom_ui/autoResizeTextArea"; +import { AutosizeTextarea } from "~/components/custom_ui/autoResizeTextArea"; import { EmojiPicker, EmojiPickerContent, @@ -26,59 +21,60 @@ import { } from "~/components/ui/popover"; import { useThrottledIsTypingMutation } from "~/hooks/chatHooks"; import { useMediaQuery } from "~/hooks/use-media-query"; +import { useChatContext } from "~/providers/ChatProvider"; 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) { +export default function ChatBottombar() { + const { + chatId, + session, + inputRef, + replyTrg, + setReplyTrg, + messagesContainerRef, + } = useChatContext(); 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, - }); + const { mutate: addMutation } = api.chatSSE.sendMessage.useMutation({ + onError: (err) => { + console.error("Failed to send message", err); + }, + onMutate: () => { + setReplyTrg(null); setMessage(""); - if (inputRef.current) { inputRef.current.textArea.focus(); inputRef.current.textArea.style.height = "auto"; } + }, + }); + + const handleThumbsUp = () => { + addMutation({ + reply_to_id: replyTrg?.messageid || null, + chatId, + message: "👍", + sender: session.id, + }); + }; + + const handleSend = () => { + if (message.length !== 0) { + addMutation({ + reply_to_id: replyTrg?.messageid || null, + chatId, + message: message.trim(), + sender: session.id, + }); } }; const isTypingMutation = useThrottledIsTypingMutation({ chatId, - username: userData.username, - userId: userData.id, + username: session.username, + userId: session.id, }); useEffect(() => { @@ -92,81 +88,129 @@ export default function ChatBottombar({ } }, []); const [openEmoji, setOpenEmoji] = useState(false); - + const handleJumpToBottom = useCallback(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, []); const isMobile = useMediaQuery("(min-width: 426px)"); + const [showScrollDown, setShowScrollDown] = useState(false); + + useEffect(() => { + const el = messagesContainerRef.current; + if (!el) return; + + const handleScroll = () => { + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollDown(distanceFromBottom > 150); + }; + + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [messagesContainerRef]); 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(); - // } - }} - > - - - - - +
+ {showScrollDown && ( + + )} + {replyTrg && ( +
+
+ Rispondi: +
+ {replyTrg.message} +
+
- )} +
+ )} +
+
+ +
+
+ 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); + }} + > + + + + + +
+ )} +
+
-
); } diff --git a/apps/infoalloggi/src/components/chat/chat-bubble.tsx b/apps/infoalloggi/src/components/chat/chat-bubble.tsx index e61dad5..a38e97c 100644 --- a/apps/infoalloggi/src/components/chat/chat-bubble.tsx +++ b/apps/infoalloggi/src/components/chat/chat-bubble.tsx @@ -1,12 +1,11 @@ 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 ", + "flex gap-2 max-w-[85%] items-end relative group rounded-md", { defaultVariants: { layout: "default", @@ -52,28 +51,19 @@ const ChatBubble = React.forwardRef( ); ChatBubble.displayName = "ChatBubble"; -type ChatBubbleReadStatusProps = { - isread: boolean; -}; - -export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => { +export const ChatBubbleReadStatus = ({ isread }: { isread: boolean }) => { if (isread) { - return ; + return ; } - 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", @@ -84,52 +74,23 @@ const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", { interface ChatBubbleMessageProps extends React.HTMLAttributes, - VariantProps { - isLoading?: boolean; -} + VariantProps {} const ChatBubbleMessage = React.forwardRef< HTMLDivElement, ChatBubbleMessageProps ->( - ( - { className, variant, layout, isLoading = false, children, ...props }, - ref, - ) => ( -
- {isLoading ? ( -
- -
- ) : ( - children - )} -
- ), -); +>(({ className, variant, children, ...props }, ref) => ( +
+ {children} +
+)); ChatBubbleMessage.displayName = "ChatBubbleMessage"; -// ChatBubbleTimestamp -interface ChatBubbleTimestampProps - extends React.HTMLAttributes { - timestamp: string; -} - -const ChatBubbleTimestamp: React.FC = ({ - timestamp, - className, - ...props -}) => ( -
- {timestamp} -
-); - -export { ChatBubble, ChatBubbleMessage, ChatBubbleTimestamp }; +export { ChatBubble, ChatBubbleMessage }; diff --git a/apps/infoalloggi/src/components/chat/chat-list.tsx b/apps/infoalloggi/src/components/chat/chat-list.tsx index 2fa2e8c..017a943 100644 --- a/apps/infoalloggi/src/components/chat/chat-list.tsx +++ b/apps/infoalloggi/src/components/chat/chat-list.tsx @@ -1,5 +1,7 @@ +import { format, isSameDay } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; -import { Pencil, Trash2 } from "lucide-react"; +import { Copy, Forward, Pencil, Reply, Trash2 } from "lucide-react"; +import { useRouter } from "next/router"; import { type FormEvent, useCallback, @@ -12,9 +14,9 @@ import { ChatBubble, ChatBubbleMessage, ChatBubbleReadStatus, - ChatBubbleTimestamp, } from "~/components/chat/chat-bubble"; import LoadingButton from "~/components/custom_ui/loading-button"; +import { LoadingPage } from "~/components/loading"; import { AlertDialog, AlertDialogAction, @@ -26,6 +28,14 @@ import { AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { Button } from "~/components/ui/button"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "~/components/ui/command"; import { ContextMenu, ContextMenuContent, @@ -42,35 +52,29 @@ import { 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 { cn } from "~/lib/utils"; +import { useChatContext } from "~/providers/ChatProvider"; 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 = () => { + const { + chatId, + session, + liveChat, + inputRef, + setReplyTrg, + messagesContainerRef, + } = useChatContext(); + const { messages, query, typingStatus } = liveChat; -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 [inoltraModalOpen, setInoltraModalOpen] = useState(false); const handleEditOpen = (status: boolean) => { setEditModalOpen(status); @@ -126,7 +130,7 @@ export const ChatList = ({ [editMutation], ); const [locked, setLocked] = useState(false); - + const focusInputOnCloseRef = useRef(false); useEffect(() => { if ( messagesContainerRef.current && @@ -134,16 +138,65 @@ export const ChatList = ({ !query.isLoading && !query.isFetching ) { - messagesContainerRef.current.scrollTop = - messagesContainerRef.current.scrollHeight; + messagesContainerRef.current.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: "smooth", + }); } setLocked(false); }, [messages, typingStatus]); + const handleFetchPrevious = useCallback(async () => { + if (query.hasNextPage && !query.isFetchingNextPage) { + setLocked(true); + await query.fetchNextPage(); + } + }, [query]); + + const utils = api.useUtils(); + + const handleScrollTo = (messageId: MessagesMessageid): boolean => { + const el = document.getElementById(messageId); + if (el) { + el.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + el.classList.add("bg-primary/50"); + setTimeout(() => { + el.classList.remove("bg-primary/50"); + }, 2000); + return true; + } + console.warn("Element not found for messageId:", messageId); + return false; + }; + + const handleJumpToMessage = async (messageId: MessagesMessageid) => { + const scroll = handleScrollTo(messageId); + if (scroll) return; + + const context = await utils.chatSSE.getChat.fetch({ + chatId, + targetMsgId: messageId, + }); + + utils.chatSSE.getChat.setInfiniteData({ chatId }, () => { + return { + pages: [context], + pageParams: [null] as (MessagesMessageid | null)[], + }; + }); + setTimeout(() => { + handleScrollTo(messageId); + }, 100); + console.log(query.data); + }; + return (
@@ -154,10 +207,7 @@ export const ChatList = ({ disabled={!query.hasNextPage || query.isFetchingNextPage} key="load-more" loading={query.isFetchingNextPage} - onClick={async () => { - setLocked(true); - await query.fetchNextPage(); - }} + onClick={handleFetchPrevious} variant="secondary" > {query.isFetchingNextPage @@ -167,45 +217,32 @@ export const ChatList = ({ )} {messages.map((message, index) => { - const variant = getMessageVariant(message.sender, userData.id); - const isNextSameSender = index + 1 < messages.length && messages[index + 1]?.sender === message.sender; + const isPrevSameDay = (() => { + const prevMsg = messages[index - 1]; + if (!prevMsg) return false; + return isSameDay(new Date(prevMsg.time), new Date(message.time)); + })(); 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 isSender = message.sender === session.id; - const userColor = getUserColorV2( - `${getInitials(message.sender_nome)}${message.sender}`, - ); + const userColor = getUserColorV2(message.color_str); + const reply_userColor = message.reply_source + ? getUserColorV2(message.reply_source.color_str) + : undefined; return ( - {!isPrevSameDay && ( @@ -216,72 +253,162 @@ export const ChatList = ({ })} )} - - - {isNextSameSender ? ( -
- ) : ( - - )} - - -
- {!isPreviousSameSender && ( -

- {message.sender_nome} -

- )} + {!isPreviousSameSender && ( +

+ {message.sender_nome} +

+ )} + + + + + {message.reply_source && ( + + )} {message.message} -
- {userData.id === message.sender && ( - +
{message.time && ( - +
+ {format(new Date(message.time), "HH:mm")} +
+ )} + {isSender && ( + )}
-
-
- + + + + { + if (focusInputOnCloseRef.current) { + e.preventDefault(); + inputRef.current?.textArea.focus(); + focusInputOnCloseRef.current = false; + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: "smooth", + }); + } + } + }} + > + { + navigator.clipboard.writeText(message.message); + toast.success("Messaggio copiato negli appunti", { + id: "copyMessage", + }); + }} + > + + Copia + + { + setReplyTrg(message); + + focusInputOnCloseRef.current = true; + }} + > + + Rispondi + + {session.isAdmin && ( { setMsgSelected(message); - handleDeleteOpen(true); + setInoltraModalOpen(true); }} > - - Cancella + + Inoltra + )} + + {(isSender || session.isAdmin) && ( { @@ -292,20 +419,41 @@ export const ChatList = ({ Modifica - -
- - + )} + {session.isAdmin && ( + <> + { + setMsgSelected(message); + handleDeleteOpen(true); + }} + > + + Cancella + {" "} + + )} + + +
); })}
+ {session.isAdmin && ( + setInoltraModalOpen(status)} + /> + )} - - - + + ✏️ {typer.username} sta scrivendo... + ); @@ -466,5 +609,78 @@ const DeleteMessageDialog = ({ ); }; -const getMessageVariant = (messageName: string, selectedUserName: string) => - messageName !== selectedUserName ? "received" : "sent"; +const InoltraDialog = ({ + isOpen, + openChange, + msgSelected, +}: { + isOpen: boolean; + openChange: (status: boolean) => void; + msgSelected: Message | null; +}) => { + const router = useRouter(); + const { chatId, session } = useChatContext(); + const { data: activeChats, isLoading } = api.chat.getActiveChats.useQuery( + undefined, + { + enabled: isOpen, + }, + ); + const { mutate } = api.chatSSE.sendMessage.useMutation({ + onError: () => { + toast.error("Errore nell'inoltro del messaggio", { + id: "forwardMessage", + }); + }, + onMutate: () => { + toast("Inoltrando messaggio...", { icon: "📨", id: "forwardMessage" }); + }, + onSuccess: () => { + toast("Messaggio inoltrato", { icon: "📨", id: "forwardMessage" }); + }, + }); + return ( + + + + Nessun risultato + {isLoading && } + + {activeChats + ?.filter((chat) => chat.chatid !== chatId) + .map((chat) => ( + { + if (!msgSelected) return; + mutate({ + chatId: chat.chatid, + message: msgSelected.message, + sender: session.id, + is_forwarded: true, + }); + router.push(`/area-riservata/admin/chats/${chat.chatid}`); + }} + > + + + + + {chat.username} + + + + ))} + + + + ); +}; diff --git a/apps/infoalloggi/src/components/chat/chat-sidebar.tsx b/apps/infoalloggi/src/components/chat/chat-sidebar.tsx index 8877299..fae1438 100644 --- a/apps/infoalloggi/src/components/chat/chat-sidebar.tsx +++ b/apps/infoalloggi/src/components/chat/chat-sidebar.tsx @@ -289,7 +289,7 @@ const SearchUserComponent = ({ > {activeChats?.map((chat) => ( { handleChatSelection(chat.chatid); @@ -344,7 +344,7 @@ const NewUserComponent = ({ > {inactiveUsers?.map((user) => ( { createNewChat({ diff --git a/apps/infoalloggi/src/components/chat/chat-topbar.tsx b/apps/infoalloggi/src/components/chat/chat-topbar.tsx index 19bcd3e..4c7267b 100644 --- a/apps/infoalloggi/src/components/chat/chat-topbar.tsx +++ b/apps/infoalloggi/src/components/chat/chat-topbar.tsx @@ -25,21 +25,11 @@ import { import { Separator } from "~/components/ui/separator"; import { UserAvatar } from "~/components/user_avatar"; import { cn } from "~/lib/utils"; -import type { ChatsChatid } from "~/schemas/public/Chats"; -import type { Session } from "~/server/api/trpc"; -import type { ChatUserInfo } from "~/server/services/chat.service"; +import { useChatContext } from "~/providers/ChatProvider"; import { api } from "~/utils/api"; -type ChatTopbarProps = { - chatId: ChatsChatid; - userData: Session; - chatUserData: ChatUserInfo; -}; -export default function ChatTopbar({ - chatId, - userData, - chatUserData, -}: ChatTopbarProps) { +export default function ChatTopbar() { + const { chatId, session, userinfo } = useChatContext(); const utils = api.useUtils(); const { mutate: deletechat } = api.chat.deleteChat.useMutation({ onSuccess: async () => { @@ -62,13 +52,13 @@ export default function ChatTopbar({ }, [chatId, deletechat]); const handleSwapUser = useCallback(() => { - swap({ id: chatUserData.id }); - }, [chatUserData, swap]); + swap({ id: userinfo.id }); + }, [userinfo, swap]); return (
- {!userData.isAdmin ? ( + {!session.isAdmin ? ( Infoalloggi logo )}
- {!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username} + {!session.isAdmin ? "Chat Infoalloggi" : userinfo.username} - {userData.isAdmin && } + {session.isAdmin && }
- {userData.isAdmin && ( + {session.isAdmin && ( @@ -107,96 +96,97 @@ export default function ChatTopbar({ } const AdminPopover = ({ - chatUserData, handleSwapUser, handleDeleteChat, }: { - chatUserData: ChatUserInfo; handleSwapUser: () => void; handleDeleteChat: () => void; -}) => ( - - - - - -
- - Profilo - +}) => { + const { userinfo } = useChatContext(); + return ( + + + + + +
+ + Profilo + - - Ordini - - - Allegati - - - - - - - - - - Sei assolutamente sicuro? - - Questa azione non può essere annullata. Questo eliminerà - definitivamente la chat e rimuoverà i dati dai nostri server. - - - - Annulla - - Continua - - - - -
-
-
-); + + Ordini + + + Allegati + + + + + + + + + + Sei assolutamente sicuro? + + Questa azione non può essere annullata. Questo eliminerà + definitivamente la chat e rimuoverà i dati dai nostri server. + + + + Annulla + + Continua + + + + +
+
+
+ ); +}; diff --git a/apps/infoalloggi/src/components/chat/chat.tsx b/apps/infoalloggi/src/components/chat/chat.tsx index f5e4209..8ba6d34 100644 --- a/apps/infoalloggi/src/components/chat/chat.tsx +++ b/apps/infoalloggi/src/components/chat/chat.tsx @@ -3,6 +3,7 @@ import ChatBottombar from "~/components/chat/chat-bottombar"; import { ChatList } from "~/components/chat/chat-list"; import ChatTopbar from "~/components/chat/chat-topbar"; import { useLiveChat } from "~/hooks/chatHooks"; +import { ChatProvider } from "~/providers/ChatProvider"; import type { ChatsChatid } from "~/schemas/public/Chats"; import type { Session } from "~/server/api/trpc"; import { api } from "~/utils/api"; @@ -15,30 +16,17 @@ const Chat = memo( }); return ( -
-
- + +
+
+ +
+ +
+ +
- - -
- -
-
+ ); }, (prevProps, nextProps) => diff --git a/apps/infoalloggi/src/components/chat/message-loading.tsx b/apps/infoalloggi/src/components/chat/message-loading.tsx deleted file mode 100644 index ed52f00..0000000 --- a/apps/infoalloggi/src/components/chat/message-loading.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// @hidden -export default function MessageLoading() { - return ( - - Loading - - - - - - - - - - - ); -} diff --git a/apps/infoalloggi/src/components/user_avatar.tsx b/apps/infoalloggi/src/components/user_avatar.tsx index 90c7eb9..bed7adb 100644 --- a/apps/infoalloggi/src/components/user_avatar.tsx +++ b/apps/infoalloggi/src/components/user_avatar.tsx @@ -4,8 +4,8 @@ import { cn, getInitials } from "~/lib/utils"; import type { UsersId } from "~/schemas/public/Users"; const user_colors = [ - "#2D3436", - "#535C68", + //"#2D3436", + //"#535C68", "#636E72", "#FAB1A0", "#FF7675", @@ -23,11 +23,11 @@ const user_colors = [ "#81ECEC", "#74B9FF", "#0984E3", - "#4834D4", - "#6C5CE7", + //"#5e4ae8", + //"#6C5CE7", "#A29BFE", "#A55EEA", - "#6D214F", + //"#6D214F", ]; function stringHash(str: string): number { @@ -80,9 +80,8 @@ export const UserAvatar = ({
); } - const value = `${getInitials(username)}${userId}`; - const userColor = getUserColorV2(value); + const userColor = getUserColorV2(username + userId); return (
{ const { status, user } = useSession(); - const { data: activeChats, isLoading: isLoadingActiveChats } = - api.chat.getActiveChats.useQuery(); - - const { data: inactiveUsers, isLoading: isLoadingInactiveUsers } = - api.users.getActiveUsersWithoutChat.useQuery(); - - if (status === "LOADING" || isLoadingActiveChats || isLoadingInactiveUsers) - return ; + if (status === "LOADING") return ; if (status !== "AUTHENTICATED") { window.location.reload(); return ; } - if (!activeChats || !inactiveUsers) return ; return ( <> diff --git a/apps/infoalloggi/src/providers/ChatProvider.tsx b/apps/infoalloggi/src/providers/ChatProvider.tsx new file mode 100644 index 0000000..40e07ca --- /dev/null +++ b/apps/infoalloggi/src/providers/ChatProvider.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useRef, useState } from "react"; +import type { AutosizeTextAreaRef } from "~/components/custom_ui/autoResizeTextArea"; +import type { LiveChat } from "~/hooks/chatHooks"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { MessagesMessageid } from "~/schemas/public/Messages"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; + +type ReplyTrg = { + messageid: MessagesMessageid; + message: string; +}; + +type ChatContextType = { + liveChat: LiveChat; + chatId: ChatsChatid; + userinfo: ChatUserInfo; + session: Session; + replyTrg: ReplyTrg | null; + setReplyTrg: React.Dispatch>; + inputRef: React.RefObject; + messagesContainerRef: React.RefObject; +}; + +type ChatContextProps = { + liveChat: LiveChat; + chatId: ChatsChatid; + userinfo: ChatUserInfo; + session: Session; +}; +const ChatContext = createContext(null); + +export const ChatProvider = ({ + children, + values, +}: { + children: React.ReactNode; + values: ChatContextProps; +}) => { + const [replyTrg, setReplyTrg] = useState(null); + const inputRef = useRef(null); + const messagesContainerRef = useRef(null); + return ( + + {children} + + ); +}; + +export const useChatContext = () => { + const ctx = useContext(ChatContext); + if (!ctx) throw new Error("useChatContext must be used inside ChatProvider"); + return ctx; +}; diff --git a/apps/infoalloggi/src/schemas/public/Messages.ts b/apps/infoalloggi/src/schemas/public/Messages.ts index 09fa855..05112c5 100644 --- a/apps/infoalloggi/src/schemas/public/Messages.ts +++ b/apps/infoalloggi/src/schemas/public/Messages.ts @@ -14,13 +14,17 @@ export default interface MessagesTable { chatid: ColumnType; - message: ColumnType; + message: ColumnType; time: ColumnType; isread: ColumnType; sender: ColumnType; + + reply_to_id: ColumnType; + + is_forwarded: ColumnType; } export type Messages = Selectable; diff --git a/apps/infoalloggi/src/server/api/routers/chat_sse.ts b/apps/infoalloggi/src/server/api/routers/chat_sse.ts index 465612e..0c740bb 100644 --- a/apps/infoalloggi/src/server/api/routers/chat_sse.ts +++ b/apps/infoalloggi/src/server/api/routers/chat_sse.ts @@ -1,5 +1,7 @@ import { on } from "node:events"; import { TRPCError, tracked } from "@trpc/server"; +import { sql } from "kysely"; +import { jsonObjectFrom } from "kysely/helpers/postgres"; import { z } from "zod/v4"; import type { MessagesMessageid } from "~/schemas/public/Messages"; import { @@ -25,6 +27,7 @@ import { zUserId, } from "~/server/utils/zod_types"; import { getKeydbClient, getSubClient } from "~/utils/keydb"; +import { withUserColorStr } from "~/utils/kysely-helper"; const getChatChannel = (chatId: string) => `chat:${chatId}:messages`; const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`; @@ -55,9 +58,17 @@ export const chatSSERouter = createTRPCRouter({ chatId: zChatId, cursor: zMessageId.nullish(), take: z.number().min(1).max(50).nullish(), + targetMsgId: zMessageId.nullish(), + }), + ) + .output( + z.object({ + items: z.array(MessageDataSchema), + nextCursor: zMessageId.nullable(), }), ) .query(async ({ input, ctx }) => { + const take = input.take ?? 30; try { const didUpdate = await setReadMessage({ chatId: input.chatId, @@ -75,17 +86,15 @@ export const chatSSERouter = createTRPCRouter({ } } - const take = input.take ?? 20; - const cursor = input.cursor; - - let qry = db - .selectFrom("messages") - .innerJoin("users", "users.id", "messages.sender") - .select([ - "messages.message", - "messages.messageid", - "messages.chatid", - (eb) => + if (input.targetMsgId) { + // If a targetMsgId is provided, we want to fetch messages around that message + const msgs = await db + .selectFrom("messages") + .innerJoin("users", "users.id", "messages.sender") + .select((eb) => [ + "messages.message", + "messages.messageid", + "messages.chatid", eb .cast( eb.fn("to_char", [ @@ -95,11 +104,91 @@ export const chatSSERouter = createTRPCRouter({ "text", ) .as("time"), + "messages.sender", + "messages.isread", + "messages.is_forwarded", + "messages.reply_to_id", + "users.nome as sender_nome", + "users.isAdmin as sender_isAdmin", + withUserColorStr(), + jsonObjectFrom( + eb + .selectFrom("messages as reply_source") + .select([ + "reply_source.messageid", + "reply_source.message", + "reply_source.sender", + ]) + .innerJoin("users", "users.id", "reply_source.sender") + .select(["users.nome", withUserColorStr()]) + .whereRef( + "messages.reply_to_id", + "=", + "reply_source.messageid", + ) + .limit(1), + ).as("reply_source"), + ]) + .where("messages.chatid", "=", input.chatId) + .where("messages.messageid", ">=", input.targetMsgId) + .orderBy("messages.messageid", "asc") + .execute(); + const prevCheck = await db + .selectFrom("messages") + .where("chatid", "=", input.chatId) + .where("messageid", "<", input.targetMsgId) + .select("messageid") + .orderBy("messageid", "desc") + .limit(1) + .executeTakeFirst(); + + return { + items: msgs, + nextCursor: prevCheck?.messageid ?? null, + }; + } + + const cursor = input.cursor; + + let qry = db + .selectFrom("messages") + .innerJoin("users", "users.id", "messages.sender") + .select((eb) => [ + "messages.message", + "messages.messageid", + "messages.chatid", + + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), "messages.sender", "messages.isread", + "messages.is_forwarded", + "messages.reply_to_id", "users.nome as sender_nome", "users.isAdmin as sender_isAdmin", + withUserColorStr(), + jsonObjectFrom( + eb + .selectFrom("messages as reply_source") + .select([ + "reply_source.messageid", + "reply_source.message", + "reply_source.sender", + ]) + .innerJoin("users", "users.id", "reply_source.sender") + .select(["users.nome", withUserColorStr()]) + .whereRef("messages.reply_to_id", "=", "reply_source.messageid") + .limit(1), + ).as("reply_source"), ]) + .where("users.nome", "is not", null) .where("messages.chatid", "=", input.chatId) .orderBy("messages.messageid", "desc") @@ -134,6 +223,8 @@ export const chatSSERouter = createTRPCRouter({ chatId: zChatId, message: z.string(), sender: zUserId, + is_forwarded: z.boolean().default(false), + reply_to_id: zMessageId.nullable().default(null), }), ) .mutation(async ({ input }) => { @@ -144,15 +235,33 @@ export const chatSSERouter = createTRPCRouter({ message: input.message, sender: input.sender, time: new Date(), + is_forwarded: input.is_forwarded, + reply_to_id: input.reply_to_id, }) .returningAll() .executeTakeFirstOrThrow( () => new Error("Failed to insert message into database"), ); + const rs = await db + .selectFrom("messages") + .innerJoin("users", "users.id", "messages.sender") + .select([ + "messages.messageid", + "messages.message", + "messages.sender", + "users.nome", + withUserColorStr(), + ]) + .where("messages.messageid", "=", newMessage.reply_to_id) + .executeTakeFirst(); const userInfos = await db .selectFrom("users") - .select(["nome as sender_nome", "isAdmin as sender_isAdmin"]) + .select([ + "nome as sender_nome", + "isAdmin as sender_isAdmin", + sql`concat(username, '', id)`.as("color_str"), + ]) .where("id", "=", input.sender) .executeTakeFirst(); @@ -165,6 +274,16 @@ export const chatSSERouter = createTRPCRouter({ const payload: Message = { ...newMessage, ...userInfos, + + reply_source: rs + ? { + messageid: rs.messageid, + message: rs.message, + nome: rs.nome, + sender: rs.sender, + color_str: rs.color_str, + } + : null, time: newMessage.time.toISOString(), }; @@ -393,24 +512,39 @@ export const chatSSERouter = createTRPCRouter({ const newMessagesSinceLast = await db .selectFrom("messages") .innerJoin("users", "users.id", "messages.sender") - .select([ + .select((eb) => [ "messages.message", "messages.messageid", "messages.chatid", - (eb) => - eb - .cast( - eb.fn("to_char", [ - "messages.time", - eb.val("YYYY-MM-DD HH24:MI:SS"), - ]), - "text", - ) - .as("time"), + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), "messages.sender", "messages.isread", + "messages.is_forwarded", + "messages.reply_to_id", "users.nome as sender_nome", "users.isAdmin as sender_isAdmin", + withUserColorStr(), + jsonObjectFrom( + eb + .selectFrom("messages as reply_source") + .select([ + "reply_source.messageid", + "reply_source.message", + "reply_source.sender", + ]) + .innerJoin("users", "users.id", "reply_source.sender") + .select(["users.nome", withUserColorStr()]) + .whereRef("messages.reply_to_id", "=", "reply_source.messageid") + .limit(1), + ).as("reply_source"), ]) .where("messages.chatid", "=", input.chatId) .where("messages.messageid", ">", lastMessageId) diff --git a/apps/infoalloggi/src/server/controllers/chat.controller.ts b/apps/infoalloggi/src/server/controllers/chat.controller.ts index 70e36a5..14b24b1 100644 --- a/apps/infoalloggi/src/server/controllers/chat.controller.ts +++ b/apps/infoalloggi/src/server/controllers/chat.controller.ts @@ -10,7 +10,10 @@ import { getUserInfo } from "~/utils/kysely-helper"; export type ActiveChatsType = Pick & Pick & { - lastmessage: Message | null; + lastmessage: Pick< + Message, + "messageid" | "message" | "time" | "sender" | "isread" + > | null; etichette: Etichette[]; }; @@ -31,24 +34,20 @@ export const getActiveInfos = async (): Promise => { .selectFrom("messages") .whereRef("messages.chatid", "=", "chats.chatid") .innerJoin("users", "users.id", "messages.sender") - .select([ + .select((eb) => [ "messages.message", "messages.messageid", - "messages.chatid", - (eb) => - eb - .cast( - eb.fn("to_char", [ - "messages.time", - eb.val("YYYY-MM-DD HH24:MI:SS"), - ]), - "text", - ) - .as("time"), + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), "messages.sender", "messages.isread", - "users.nome as sender_nome", - "users.isAdmin as sender_isAdmin", ]) .orderBy("messages.messageid", "desc") .limit(1), diff --git a/apps/infoalloggi/src/server/utils/zod_types.ts b/apps/infoalloggi/src/server/utils/zod_types.ts index 78677ad..99ce307 100644 --- a/apps/infoalloggi/src/server/utils/zod_types.ts +++ b/apps/infoalloggi/src/server/utils/zod_types.ts @@ -100,13 +100,25 @@ export const zEventId = z.custom( export const MessageDataSchema = z.object({ messageid: zMessageId, - message: z.string().nullable(), + message: z.string(), chatid: zChatId, time: z.string(), sender_nome: z.string(), sender_isAdmin: z.boolean(), sender: zUserId, isread: z.boolean(), + color_str: z.string(), + is_forwarded: z.boolean(), + reply_to_id: zMessageId.nullable(), + reply_source: z + .object({ + messageid: zMessageId, + message: z.string(), + nome: z.string(), + sender: zUserId, + color_str: z.string(), + }) + .nullable(), }); export const TypingDataSchema = z.array( @@ -114,7 +126,7 @@ export const TypingDataSchema = z.array( userId: zUserId, username: z.string(), }), -); +); export const SidebarUpdateSchema = z.object({ chatId: zChatId, diff --git a/apps/infoalloggi/src/utils/kysely-helper.ts b/apps/infoalloggi/src/utils/kysely-helper.ts index 5010437..2c9651d 100644 --- a/apps/infoalloggi/src/utils/kysely-helper.ts +++ b/apps/infoalloggi/src/utils/kysely-helper.ts @@ -1,4 +1,4 @@ -import { expressionBuilder } from "kysely"; +import { expressionBuilder, sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import type Database from "~/schemas/Database"; @@ -41,3 +41,7 @@ export function withVideos() { // const date = new Date(str); // return isValid(date) ? date : null; // } + +export function withUserColorStr() { + return sql`concat(users.username, '', users.id)`.as("color_str"); +}