feat(chat): refactor chat components to use ChatProvider for context management

- Updated ChatTopbar and Chat components to utilize ChatProvider for managing chat state and user information.
- Removed unnecessary props from ChatTopbar and adjusted its implementation to fetch data from context.
- Added ChatProvider to encapsulate chat-related state and functionality.
- Enhanced message handling by introducing reply and forward features in the chat schema and API.
- Cleaned up unused components and optimized imports across chat-related files.
- Improved user avatar color generation logic and removed hardcoded color values.
- Updated loading states and error handling in admin chat management pages.
This commit is contained in:
Marco Pedone 2026-04-27 16:52:32 +02:00
parent 1971251490
commit 98463da038
17 changed files with 905 additions and 552 deletions

View file

@ -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;

View file

@ -17,26 +17,19 @@ import { LoadingPage } from "~/components/loading";
import { Button, buttonVariants } from "~/components/ui/button"; import { Button, buttonVariants } from "~/components/ui/button";
import { UploadComponent } from "~/components/upload_modal"; import { UploadComponent } from "~/components/upload_modal";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider"; 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"; import { api } from "~/utils/api";
type ChatAttachmentsProps = { export const ChatAttachments = () => {
userData: Session; const { session, userinfo } = useChatContext();
chatUserData: ChatUserInfo;
};
export const ChatAttachments = ({
userData,
chatUserData,
}: ChatAttachmentsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
data: attachments, data: attachments,
isLoading: attachmentsLoading, isLoading: attachmentsLoading,
refetch, refetch,
} = api.storage.retrieveUserFileData.useQuery( } = api.storage.retrieveUserFileData.useQuery(
{ userId: chatUserData.id }, { userId: userinfo.id },
{ refetchOnMount: true }, { refetchOnMount: true },
); );
@ -65,10 +58,7 @@ export const ChatAttachments = ({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-5 overflow-auto pb-5 md:px-1"> <CredenzaBody className="max-h-[80vh] space-y-5 overflow-auto pb-5 md:px-1">
<UploadComponent <UploadComponent isAdmin={session.isAdmin} userId={userinfo.id} />
isAdmin={userData.isAdmin}
userId={chatUserData.id}
/>
<div className="h-auto max-h-160 max-w-lg overflow-auto rounded-lg"> <div className="h-auto max-h-160 max-w-lg overflow-auto rounded-lg">
{attachmentsLoading ? ( {attachmentsLoading ? (

View file

@ -1,18 +1,13 @@
"use client";
import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react";
import { import {
type ChangeEvent, ArrowDown,
useCallback, SendHorizontal,
useEffect, SmilePlusIcon,
useRef, ThumbsUp,
useState, X,
} from "react"; } from "lucide-react";
import { type ChangeEvent, useCallback, useEffect, useState } from "react";
import { ChatAttachments } from "~/components/chat/chat-attachments"; import { ChatAttachments } from "~/components/chat/chat-attachments";
import { import { AutosizeTextarea } from "~/components/custom_ui/autoResizeTextArea";
type AutosizeTextAreaRef,
AutosizeTextarea,
} from "~/components/custom_ui/autoResizeTextArea";
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerContent, EmojiPickerContent,
@ -26,59 +21,60 @@ import {
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks"; import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
import { useMediaQuery } from "~/hooks/use-media-query"; import { useMediaQuery } from "~/hooks/use-media-query";
import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider"; 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"; import { api } from "~/utils/api";
type ChatBottombarProps = { export default function ChatBottombar() {
chatId: ChatsChatid; const {
userData: Session; chatId,
chatUserData: ChatUserInfo; session,
}; inputRef,
replyTrg,
export default function ChatBottombar({ setReplyTrg,
chatId, messagesContainerRef,
userData, } = useChatContext();
chatUserData,
}: ChatBottombarProps) {
const { locale } = useTranslation(); const { locale } = useTranslation();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<AutosizeTextAreaRef>(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(""); setMessage("");
if (inputRef.current) { if (inputRef.current) {
inputRef.current.textArea.focus(); inputRef.current.textArea.focus();
inputRef.current.textArea.style.height = "auto"; 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({ const isTypingMutation = useThrottledIsTypingMutation({
chatId, chatId,
username: userData.username, username: session.username,
userId: userData.id, userId: session.id,
}); });
useEffect(() => { useEffect(() => {
@ -92,81 +88,129 @@ export default function ChatBottombar({
} }
}, []); }, []);
const [openEmoji, setOpenEmoji] = useState(false); 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 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 ( return (
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2"> <div className="relative flex w-full flex-col border-t">
<div className="flex"> {showScrollDown && (
<ChatAttachments chatUserData={chatUserData} userData={userData} /> <Button
</div> className="absolute -top-1/2 left-1/2 size-12 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-80"
<div className="relative w-full"> onClick={handleJumpToBottom}
<AutosizeTextarea variant="outline"
autoComplete="off" >
className="flex h-14 min-h-14 w-full resize-none items-center overflow-hidden rounded-xl border bg-background pr-8 outline-hidden ring-0 hover:border-neutral-400 focus:border-neutral-600 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" <ArrowDown className="size-6" />
minHeight={56} </Button>
name="message" )}
onBlur={() => setIsFocused(false)} {replyTrg && (
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { <div className="p-2">
setMessage(e.target.value); <div className="relative flex-1 rounded-lg border bg-muted p-2 text-sm">
}} <span>Rispondi:</span>
onFocus={() => setIsFocused(true)} <div className="line-clamp-2 overflow-hidden text-ellipsis whitespace-pre-wrap text-sm">
onKeyDown={(e) => { {replyTrg.message}
if (e.key === "Enter") { </div>
e.preventDefault(); <Button
if (e.shiftKey) { className="absolute top-1 right-1"
setMessage((prev) => `${prev}\n`); onClick={() => setReplyTrg(null)}
} else { size="icon"
handleSend(); variant="outline"
} >
} <X className="size-4" />
}} </Button>
placeholder="Aa"
ref={inputRef}
value={message}
/>
{isMobile && (
<div className="absolute top-0 right-2 flex h-full items-center">
<Popover onOpenChange={setOpenEmoji} open={openEmoji}>
<PopoverTrigger asChild>
<Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0">
<EmojiPicker
className="h-84"
locale={locale as "it" | "en"}
onEmojiSelect={({ emoji }) => {
setMessage(message + emoji);
// if (inputRef.current) {
// inputRef.current.textArea.focus();
// }
}}
>
<EmojiPickerSearch
placeholder={locale === "en" ? "Search..." : "Cerca..."}
/>
<EmojiPickerContent />
</EmojiPicker>
</PopoverContent>
</Popover>
</div> </div>
)} </div>
)}
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments />
</div>
<div className="relative w-full">
<AutosizeTextarea
autoComplete="off"
className="flex h-14 min-h-14 w-full resize-none items-center overflow-hidden rounded-xl border bg-background pr-8 outline-hidden ring-0 hover:border-neutral-400 focus:border-neutral-600 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
minHeight={56}
name="message"
onBlur={() => setIsFocused(false)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
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 && (
<div className="absolute top-0 right-2 flex h-full items-center">
<Popover onOpenChange={setOpenEmoji} open={openEmoji}>
<PopoverTrigger asChild>
<Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0">
<EmojiPicker
className="h-84"
locale={locale as "it" | "en"}
onEmojiSelect={({ emoji }) => {
setMessage(message + emoji);
}}
>
<EmojiPickerSearch
placeholder={locale === "en" ? "Search..." : "Cerca..."}
/>
<EmojiPickerContent />
</EmojiPicker>
</PopoverContent>
</Popover>
</div>
)}
</div>
<Button
aria-label="Send Message"
className="shrink-0"
onClick={
message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
}
variant="ghost"
>
{message.length !== 0 ? (
<SendHorizontal className="text-muted-foreground" size={20} />
) : (
<ThumbsUp className="text-muted-foreground" size={20} />
)}
</Button>
</div> </div>
<Button
aria-label="Send Message"
className="shrink-0"
onClick={
message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
}
variant="ghost"
>
{message.length !== 0 ? (
<SendHorizontal className="text-muted-foreground" size={20} />
) : (
<ThumbsUp className="text-muted-foreground" size={20} />
)}
</Button>
</div> </div>
); );
} }

View file

@ -1,12 +1,11 @@
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Check, CheckCheck } from "lucide-react"; import { Check, CheckCheck } from "lucide-react";
import * as React from "react"; import * as React from "react";
import MessageLoading from "~/components/chat/message-loading";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
// ChatBubble // ChatBubble
const chatBubbleVariant = cva( 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: { defaultVariants: {
layout: "default", layout: "default",
@ -52,28 +51,19 @@ const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
); );
ChatBubble.displayName = "ChatBubble"; ChatBubble.displayName = "ChatBubble";
type ChatBubbleReadStatusProps = { export const ChatBubbleReadStatus = ({ isread }: { isread: boolean }) => {
isread: boolean;
};
export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => {
if (isread) { if (isread) {
return <CheckCheck className="mt-2 size-4 text-green-500" />; return <CheckCheck className="size-4 text-green-500" />;
} }
return <Check className="mt-2 size-4 text-muted-foreground" />; return <Check className="size-4 text-muted" />;
}; };
// ChatBubbleMessage // ChatBubbleMessage
const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", { const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", {
defaultVariants: { defaultVariants: {
layout: "default",
variant: "received", variant: "received",
}, },
variants: { variants: {
layout: {
ai: "border-t w-full rounded-none bg-transparent",
default: "",
},
variant: { variant: {
received: received:
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg", "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 interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> { VariantProps<typeof chatBubbleMessageVariants> {}
isLoading?: boolean;
}
const ChatBubbleMessage = React.forwardRef< const ChatBubbleMessage = React.forwardRef<
HTMLDivElement, HTMLDivElement,
ChatBubbleMessageProps ChatBubbleMessageProps
>( >(({ className, variant, children, ...props }, ref) => (
( <div
{ className, variant, layout, isLoading = false, children, ...props }, className={cn(
ref, chatBubbleMessageVariants({ className, variant }),
) => ( "wrap-break-word whitespace-pre-wrap",
<div )}
className={cn( ref={ref}
chatBubbleMessageVariants({ className, layout, variant }), {...props}
"wrap-break-word whitespace-pre-wrap", >
)} {children}
ref={ref} </div>
{...props} ));
>
{isLoading ? (
<div className="flex items-center space-x-2">
<MessageLoading />
</div>
) : (
children
)}
</div>
),
);
ChatBubbleMessage.displayName = "ChatBubbleMessage"; ChatBubbleMessage.displayName = "ChatBubbleMessage";
// ChatBubbleTimestamp export { ChatBubble, ChatBubbleMessage };
interface ChatBubbleTimestampProps
extends React.HTMLAttributes<HTMLDivElement> {
timestamp: string;
}
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
timestamp,
className,
...props
}) => (
<div className={cn("mt-2 text-right text-xs", className)} {...props}>
{timestamp}
</div>
);
export { ChatBubble, ChatBubbleMessage, ChatBubbleTimestamp };

View file

@ -1,5 +1,7 @@
import { format, isSameDay } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; 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 { import {
type FormEvent, type FormEvent,
useCallback, useCallback,
@ -12,9 +14,9 @@ import {
ChatBubble, ChatBubble,
ChatBubbleMessage, ChatBubbleMessage,
ChatBubbleReadStatus, ChatBubbleReadStatus,
ChatBubbleTimestamp,
} from "~/components/chat/chat-bubble"; } from "~/components/chat/chat-bubble";
import LoadingButton from "~/components/custom_ui/loading-button"; import LoadingButton from "~/components/custom_ui/loading-button";
import { LoadingPage } from "~/components/loading";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -26,6 +28,14 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -42,35 +52,29 @@ import {
import Input from "~/components/ui/input"; import Input from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { getUserColorV2, UserAvatar } from "~/components/user_avatar"; import { getUserColorV2, UserAvatar } from "~/components/user_avatar";
import type { LiveChat } from "~/hooks/chatHooks"; import { cn } from "~/lib/utils";
import { cn, getInitials } from "~/lib/utils"; import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { MessagesMessageid } from "~/schemas/public/Messages"; import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import type { Message, TypingData } from "~/server/api/routers/chat_sse"; import type { Message, TypingData } from "~/server/api/routers/chat_sse";
import type { Session } from "~/server/api/trpc";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
type ChatListProps = { export const ChatList = () => {
chatId: ChatsChatid; const {
messages: Message[]; chatId,
userData: Session; session,
query: LiveChat["query"]; liveChat,
typingStatus: TypingData; inputRef,
}; setReplyTrg,
messagesContainerRef,
} = useChatContext();
const { messages, query, typingStatus } = liveChat;
export const ChatList = ({
chatId,
messages,
userData,
query,
typingStatus,
}: ChatListProps) => {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<Message | null>(null); const [msgSelected, setMsgSelected] = useState<Message | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [inoltraModalOpen, setInoltraModalOpen] = useState(false);
const handleEditOpen = (status: boolean) => { const handleEditOpen = (status: boolean) => {
setEditModalOpen(status); setEditModalOpen(status);
@ -126,7 +130,7 @@ export const ChatList = ({
[editMutation], [editMutation],
); );
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
const focusInputOnCloseRef = useRef(false);
useEffect(() => { useEffect(() => {
if ( if (
messagesContainerRef.current && messagesContainerRef.current &&
@ -134,16 +138,65 @@ export const ChatList = ({
!query.isLoading && !query.isLoading &&
!query.isFetching !query.isFetching
) { ) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollTo({
messagesContainerRef.current.scrollHeight; top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
} }
setLocked(false); setLocked(false);
}, [messages, typingStatus]); }, [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 ( return (
<div className="flex h-full w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto">
<div <div
className={cn("flex h-full w-full flex-col overflow-y-auto p-4")} className={cn("flex h-full w-full flex-col overflow-y-auto p-1")}
ref={messagesContainerRef} ref={messagesContainerRef}
> >
<AnimatePresence key="chat-messages"> <AnimatePresence key="chat-messages">
@ -154,10 +207,7 @@ export const ChatList = ({
disabled={!query.hasNextPage || query.isFetchingNextPage} disabled={!query.hasNextPage || query.isFetchingNextPage}
key="load-more" key="load-more"
loading={query.isFetchingNextPage} loading={query.isFetchingNextPage}
onClick={async () => { onClick={handleFetchPrevious}
setLocked(true);
await query.fetchNextPage();
}}
variant="secondary" variant="secondary"
> >
{query.isFetchingNextPage {query.isFetchingNextPage
@ -167,45 +217,32 @@ export const ChatList = ({
)} )}
{messages.map((message, index) => { {messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id);
const isNextSameSender = const isNextSameSender =
index + 1 < messages.length && index + 1 < messages.length &&
messages[index + 1]?.sender === message.sender; 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 = const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender === message.sender; index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
const isPrevSameDay = const isSender = message.sender === session.id;
index - 1 >= 0 &&
// biome-ignore lint/style/noNonNullAssertion: <non lo so>
new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
const userColor = getUserColorV2( const userColor = getUserColorV2(message.color_str);
`${getInitials(message.sender_nome)}${message.sender}`, const reply_userColor = message.reply_source
); ? getUserColorV2(message.reply_source.color_str)
: undefined;
return ( return (
<motion.div <div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className={cn( className={cn(
"flex flex-col gap-2 pb-4", "flex flex-col gap-0.5 pb-4",
isNextSameSender && "pb-0.5", isNextSameSender && "pb-0.5",
)} )}
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
key={message.messageid} key={message.messageid}
layout
style={{ originX: 0.5, originY: 0.5 }}
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
type: "spring",
},
opacity: { duration: 0.1 },
}}
> >
{!isPrevSameDay && ( {!isPrevSameDay && (
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-muted-foreground text-xs"> <span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-muted-foreground text-xs">
@ -216,72 +253,162 @@ export const ChatList = ({
})} })}
</span> </span>
)} )}
{!isPreviousSameSender && (
<ChatBubble variant={variant}> <p
{isNextSameSender ? ( className={cn(
<div className="size-6 shrink-0"></div> "px-1.5 font-semibold text-sm",
) : ( isSender && "text-right",
<UserAvatar )}
className="size-6 shrink-0" style={{
showInitials={false} color: userColor,
userId={message.sender} }}
username={message.sender_nome} >
/> {message.sender_nome}
)} </p>
<ContextMenu> )}
<ContextMenuTrigger asChild disabled={!userData.isAdmin}> <ContextMenu>
<div className="flex flex-col gap-1"> <ContextMenuTrigger asChild>
{!isPreviousSameSender && ( <motion.div
<p animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className={cn( className={cn(
"font-semibold text-sm", "flex flex-col gap-2 rounded-md px-1",
variant === "sent" && "text-right", "data-[state=open]:bg-muted",
)} )}
style={{ exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
color: userColor, id={message.messageid}
}} initial={{ opacity: 0, scale: 1, x: 0, y: 25 }}
> layout
{message.sender_nome} style={{ originX: 0.25, originY: 0.25 }}
</p> transition={{
)} layout: {
bounce: 0.2,
duration: 0.2,
type: "spring",
},
opacity: { duration: 0.1 },
}}
>
<ChatBubble variant={isSender ? "sent" : "received"}>
<ChatBubbleMessage <ChatBubbleMessage
className={cn(isNextSameSender && "rounded-lg", "")} className={cn(
isLoading={false} "flex flex-wrap items-end justify-end gap-x-2 gap-y-1",
isNextSameSender && "rounded-lg",
"",
)}
variant={isSender ? "sent" : "received"}
> >
{message.reply_source && (
<button
className="group block w-full cursor-pointer text-left"
onClick={async () => {
if (!message.reply_source) return;
await handleJumpToMessage(
message.reply_source.messageid,
);
}}
type="button"
>
<div
className="mt-1 w-full rounded-md border-l-4 bg-secondary px-2 py-0.5 text-foreground group-hover:bg-secondary/90"
style={{ borderColor: reply_userColor }}
>
<span
className="font-semibold text-xs"
style={{
color: reply_userColor,
}}
>
{message.reply_source.sender === session.id
? "Tu"
: message.reply_source.nome}
</span>
<span className="line-clamp-2 block break-all text-xs italic">
{message.reply_source.message}
</span>
</div>
</button>
)}
<span className="flex shrink break-all"> <span className="flex shrink break-all">
{message.message} {message.message}
</span> </span>
<div className="flex items-center gap-2"> <div
{userData.id === message.sender && ( className={cn(
<ChatBubbleReadStatus isread={message.isread} /> "flex items-center gap-1",
isSender && "justify-end",
)} )}
>
{message.time && ( {message.time && (
<ChatBubbleTimestamp <div
timestamp={new Date( className={cn(
message.time, "text-[0.70rem]",
).toLocaleString("it", { isSender
day: "2-digit", ? "text-muted"
hour: "2-digit", : "text-muted-foreground",
minute: "2-digit", )}
month: "short", >
})} {format(new Date(message.time), "HH:mm")}
/> </div>
)}
{isSender && (
<ChatBubbleReadStatus isread={message.isread} />
)} )}
</div> </div>
</ChatBubbleMessage> </ChatBubbleMessage>
</div> </ChatBubble>
</ContextMenuTrigger> </motion.div>
<ContextMenuContent> </ContextMenuTrigger>
<ContextMenuContent
onCloseAutoFocus={(e) => {
if (focusInputOnCloseRef.current) {
e.preventDefault();
inputRef.current?.textArea.focus();
focusInputOnCloseRef.current = false;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
}
}}
>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
navigator.clipboard.writeText(message.message);
toast.success("Messaggio copiato negli appunti", {
id: "copyMessage",
});
}}
>
<Copy />
Copia
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setReplyTrg(message);
focusInputOnCloseRef.current = true;
}}
>
<Reply />
Rispondi
</ContextMenuItem>
{session.isAdmin && (
<ContextMenuItem <ContextMenuItem
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => { onClick={() => {
setMsgSelected(message); setMsgSelected(message);
handleDeleteOpen(true); setInoltraModalOpen(true);
}} }}
> >
<Trash2 /> <Forward />
Cancella Inoltra
</ContextMenuItem> </ContextMenuItem>
)}
{(isSender || session.isAdmin) && (
<ContextMenuItem <ContextMenuItem
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => { onClick={() => {
@ -292,20 +419,41 @@ export const ChatList = ({
<Pencil /> <Pencil />
Modifica Modifica
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> )}
</ContextMenu> {session.isAdmin && (
</ChatBubble> <>
</motion.div> <ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
>
<Trash2 />
Cancella
</ContextMenuItem>{" "}
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
); );
})} })}
<Typers <Typers
currentTypers={typingStatus} currentTypers={typingStatus}
messageLenght={messages.length} messageLenght={messages.length}
userId={userData.id} userId={session.id}
/> />
</AnimatePresence> </AnimatePresence>
</div> </div>
{session.isAdmin && (
<InoltraDialog
isOpen={inoltraModalOpen}
msgSelected={msgSelected}
openChange={(status) => setInoltraModalOpen(status)}
/>
)}
<EditMessageDialog <EditMessageDialog
handleEditSubmit={handleEditSubmit} handleEditSubmit={handleEditSubmit}
msgEdit={msgSelected} msgEdit={msgSelected}
@ -342,7 +490,7 @@ const Typers = ({
return ( return (
<motion.div <motion.div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className="flex flex-col gap-2 py-2" className="flex flex-col gap-2 px-1 py-2"
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }} exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }} initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
key={`typers${messageLenght}${typer.userId}`} key={`typers${messageLenght}${typer.userId}`}
@ -358,14 +506,9 @@ const Typers = ({
}} }}
> >
<ChatBubble variant={variant}> <ChatBubble variant={variant}>
<UserAvatar <ChatBubbleMessage className="text-muted-foreground text-xs">
className="size-6" {typer.username} sta scrivendo...
showInitials={false} </ChatBubbleMessage>
userId={typer.userId}
username={typer.username}
/>
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble> </ChatBubble>
</motion.div> </motion.div>
); );
@ -466,5 +609,78 @@ const DeleteMessageDialog = ({
); );
}; };
const getMessageVariant = (messageName: string, selectedUserName: string) => const InoltraDialog = ({
messageName !== selectedUserName ? "received" : "sent"; 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 (
<CommandDialog onOpenChange={openChange} open={isOpen}>
<CommandInput
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
placeholder="Digita il nome utente..."
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
{isLoading && <LoadingPage />}
<CommandGroup className="pb-2" heading={"Suggerimenti"}>
{activeChats
?.filter((chat) => chat.chatid !== chatId)
.map((chat) => (
<CommandItem
className="cursor-pointer rounded-md"
key={chat.id}
onSelect={() => {
if (!msgSelected) return;
mutate({
chatId: chat.chatid,
message: msgSelected.message,
sender: session.id,
is_forwarded: true,
});
router.push(`/area-riservata/admin/chats/${chat.chatid}`);
}}
>
<span className="flex items-center gap-2">
<UserAvatar
className="size-8"
userId={chat.id}
username={chat.username}
/>
<span>
<span className="block font-medium">{chat.username}</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};

View file

@ -289,7 +289,7 @@ const SearchUserComponent = ({
> >
{activeChats?.map((chat) => ( {activeChats?.map((chat) => (
<CommandItem <CommandItem
className="rounded-md" className="cursor-pointer rounded-md"
key={chat.id} key={chat.id}
onSelect={() => { onSelect={() => {
handleChatSelection(chat.chatid); handleChatSelection(chat.chatid);
@ -344,7 +344,7 @@ const NewUserComponent = ({
> >
{inactiveUsers?.map((user) => ( {inactiveUsers?.map((user) => (
<CommandItem <CommandItem
className="rounded-md" className="cursor-pointer rounded-md"
key={user.id} key={user.id}
onSelect={() => { onSelect={() => {
createNewChat({ createNewChat({

View file

@ -25,21 +25,11 @@ import {
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { UserAvatar } from "~/components/user_avatar"; import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats"; import { useChatContext } from "~/providers/ChatProvider";
import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
type ChatTopbarProps = { export default function ChatTopbar() {
chatId: ChatsChatid; const { chatId, session, userinfo } = useChatContext();
userData: Session;
chatUserData: ChatUserInfo;
};
export default function ChatTopbar({
chatId,
userData,
chatUserData,
}: ChatTopbarProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({ const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => { onSuccess: async () => {
@ -62,13 +52,13 @@ export default function ChatTopbar({
}, [chatId, deletechat]); }, [chatId, deletechat]);
const handleSwapUser = useCallback(() => { const handleSwapUser = useCallback(() => {
swap({ id: chatUserData.id }); swap({ id: userinfo.id });
}, [chatUserData, swap]); }, [userinfo, swap]);
return ( return (
<div className="flex h-18 w-full items-center justify-between border-b px-4 py-2"> <div className="flex h-18 w-full items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!userData.isAdmin ? ( {!session.isAdmin ? (
<Image <Image
alt="Infoalloggi logo" alt="Infoalloggi logo"
className="size-10 rounded-full" className="size-10 rounded-full"
@ -79,24 +69,23 @@ export default function ChatTopbar({
) : ( ) : (
<UserAvatar <UserAvatar
className="size-10" className="size-10"
userId={chatUserData.id} userId={userinfo.id}
username={chatUserData.username} username={userinfo.username}
/> />
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-4"> <div className="flex gap-4">
<span className="font-medium"> <span className="font-medium">
{!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username} {!session.isAdmin ? "Chat Infoalloggi" : userinfo.username}
</span> </span>
{userData.isAdmin && <EtichetteDisplayer chatId={chatId} />} {session.isAdmin && <EtichetteDisplayer chatId={chatId} />}
</div> </div>
</div> </div>
</div> </div>
<div> <div>
{userData.isAdmin && ( {session.isAdmin && (
<AdminPopover <AdminPopover
chatUserData={chatUserData}
handleDeleteChat={handleDeleteChat} handleDeleteChat={handleDeleteChat}
handleSwapUser={handleSwapUser} handleSwapUser={handleSwapUser}
/> />
@ -107,96 +96,97 @@ export default function ChatTopbar({
} }
const AdminPopover = ({ const AdminPopover = ({
chatUserData,
handleSwapUser, handleSwapUser,
handleDeleteChat, handleDeleteChat,
}: { }: {
chatUserData: ChatUserInfo;
handleSwapUser: () => void; handleSwapUser: () => void;
handleDeleteChat: () => void; handleDeleteChat: () => void;
}) => ( }) => {
<Popover> const { userinfo } = useChatContext();
<PopoverTrigger return (
className={cn( <Popover>
buttonVariants({ size: "icon", variant: "outline" }), <PopoverTrigger
"size-10", className={cn(
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white", buttonVariants({ size: "icon", variant: "outline" }),
)} "size-10",
> "dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white",
<Info className="text-muted-foreground" size={24} /> )}
</PopoverTrigger> >
<PopoverContent className="mr-4 w-fit px-4"> <Info className="text-muted-foreground" size={24} />
<div className="flex flex-col gap-2"> </PopoverTrigger>
<Link <PopoverContent className="mr-4 w-fit px-4">
aria-label="Visualizza Profilo Utente" <div className="flex flex-col gap-2">
className={cn( <Link
buttonVariants({ variant: "outline" }), aria-label="Visualizza Profilo Utente"
"justify-start gap-2", className={cn(
)} buttonVariants({ variant: "outline" }),
href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`} "justify-start gap-2",
> )}
<UserCog /> Profilo href={`/area-riservata/admin/user-view/edit-user/${userinfo.id}`}
</Link> >
<UserCog /> Profilo
</Link>
<Link <Link
aria-label="Visualizza Ordini Utente" aria-label="Visualizza Ordini Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
href={`/area-riservata/admin/user-view/ordini/${chatUserData.id}`} href={`/area-riservata/admin/user-view/ordini/${userinfo.id}`}
> >
<ShoppingBag /> Ordini <ShoppingBag /> Ordini
</Link> </Link>
<Link <Link
aria-label="Visualizza Allegati Utente" aria-label="Visualizza Allegati Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
href={`/area-riservata/admin/user-view/allegati/${chatUserData.id}`} href={`/area-riservata/admin/user-view/allegati/${userinfo.id}`}
> >
<Paperclip /> Allegati <Paperclip /> Allegati
</Link> </Link>
<button <button
aria-label="Cambia Utente" aria-label="Cambia Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
onClick={handleSwapUser} onClick={handleSwapUser}
type="button" type="button"
> >
<User /> Login <User /> Login
</button> </button>
<Separator /> <Separator />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button size="sm" variant="destructive"> <Button size="sm" variant="destructive">
Cancella Chat Cancella Chat
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle> <AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri server. definitivamente la chat e rimuoverà i dati dai nostri server.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel> <AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
aria-label="Continua Eliminazione Chat" aria-label="Continua Eliminazione Chat"
className={cn(buttonVariants({ variant: "destructive" }))} className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDeleteChat} onClick={handleDeleteChat}
> >
Continua Continua
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
};

View file

@ -3,6 +3,7 @@ import ChatBottombar from "~/components/chat/chat-bottombar";
import { ChatList } from "~/components/chat/chat-list"; import { ChatList } from "~/components/chat/chat-list";
import ChatTopbar from "~/components/chat/chat-topbar"; import ChatTopbar from "~/components/chat/chat-topbar";
import { useLiveChat } from "~/hooks/chatHooks"; import { useLiveChat } from "~/hooks/chatHooks";
import { ChatProvider } from "~/providers/ChatProvider";
import type { ChatsChatid } from "~/schemas/public/Chats"; import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc"; import type { Session } from "~/server/api/trpc";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -15,30 +16,17 @@ const Chat = memo(
}); });
return ( return (
<div className="flex h-full w-full flex-col justify-between"> <ChatProvider values={{ liveChat, chatId, userinfo, session: userData }}>
<div className="shrink-0"> <div className="flex h-full w-full flex-col justify-between">
<ChatTopbar <div className="shrink-0">
chatId={chatId} <ChatTopbar />
chatUserData={userinfo} </div>
userData={userData} <ChatList />
/> <div className="shrink-0">
<ChatBottombar />
</div>
</div> </div>
<ChatList </ChatProvider>
chatId={chatId}
messages={liveChat.messages}
query={liveChat.query}
typingStatus={liveChat.typingStatus}
userData={userData}
/>
<div className="shrink-0 border-t">
<ChatBottombar
chatId={chatId}
chatUserData={userinfo}
userData={userData}
/>
</div>
</div>
); );
}, },
(prevProps, nextProps) => (prevProps, nextProps) =>

View file

@ -1,46 +0,0 @@
// @hidden
export default function MessageLoading() {
return (
<svg
className="text-foreground"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Loading</title>
<circle cx="4" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="0;spinner_OcgL.end+0.25s"
calcMode="spline"
dur="0.6s"
id="spinner_qFRN"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="12" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="spinner_qFRN.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="20" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="spinner_qFRN.begin+0.2s"
calcMode="spline"
dur="0.6s"
id="spinner_OcgL"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
</svg>
);
}

View file

@ -4,8 +4,8 @@ import { cn, getInitials } from "~/lib/utils";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
const user_colors = [ const user_colors = [
"#2D3436", //"#2D3436",
"#535C68", //"#535C68",
"#636E72", "#636E72",
"#FAB1A0", "#FAB1A0",
"#FF7675", "#FF7675",
@ -23,11 +23,11 @@ const user_colors = [
"#81ECEC", "#81ECEC",
"#74B9FF", "#74B9FF",
"#0984E3", "#0984E3",
"#4834D4", //"#5e4ae8",
"#6C5CE7", //"#6C5CE7",
"#A29BFE", "#A29BFE",
"#A55EEA", "#A55EEA",
"#6D214F", //"#6D214F",
]; ];
function stringHash(str: string): number { function stringHash(str: string): number {
@ -80,9 +80,8 @@ export const UserAvatar = ({
</div> </div>
); );
} }
const value = `${getInitials(username)}${userId}`;
const userColor = getUserColorV2(value); const userColor = getUserColorV2(username + userId);
return ( return (
<div <div
{...rest} {...rest}

View file

@ -5,11 +5,9 @@ import { Sidebar } from "~/components/area-riservata/sidebar";
import { ChatSidebar } from "~/components/chat/chat-sidebar"; import { ChatSidebar } from "~/components/chat/chat-sidebar";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import { SiteHeader } from "~/components/navbar/site-header"; import { SiteHeader } from "~/components/navbar/site-header";
import { Status500 } from "~/components/status-page";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { useSession } from "~/providers/SessionProvider"; import { useSession } from "~/providers/SessionProvider";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
/** /**
* Pagina di gestione chat per admin: /area-riservata/admin/chats * Pagina di gestione chat per admin: /area-riservata/admin/chats
@ -17,20 +15,12 @@ import { api } from "~/utils/api";
const AdminChats: NextPageWithLayout = () => { const AdminChats: NextPageWithLayout = () => {
const { status, user } = useSession(); const { status, user } = useSession();
const { data: activeChats, isLoading: isLoadingActiveChats } = if (status === "LOADING") return <LoadingPage />;
api.chat.getActiveChats.useQuery();
const { data: inactiveUsers, isLoading: isLoadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery();
if (status === "LOADING" || isLoadingActiveChats || isLoadingInactiveUsers)
return <LoadingPage />;
if (status !== "AUTHENTICATED") { if (status !== "AUTHENTICATED") {
window.location.reload(); window.location.reload();
return <LoadingPage />; return <LoadingPage />;
} }
if (!activeChats || !inactiveUsers) return <Status500 />;
return ( return (
<> <>

View file

@ -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<React.SetStateAction<ReplyTrg | null>>;
inputRef: React.RefObject<AutosizeTextAreaRef | null>;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
};
type ChatContextProps = {
liveChat: LiveChat;
chatId: ChatsChatid;
userinfo: ChatUserInfo;
session: Session;
};
const ChatContext = createContext<ChatContextType | null>(null);
export const ChatProvider = ({
children,
values,
}: {
children: React.ReactNode;
values: ChatContextProps;
}) => {
const [replyTrg, setReplyTrg] = useState<ReplyTrg | null>(null);
const inputRef = useRef<AutosizeTextAreaRef>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
return (
<ChatContext.Provider
value={{ ...values, replyTrg, setReplyTrg, inputRef, messagesContainerRef }}
>
{children}
</ChatContext.Provider>
);
};
export const useChatContext = () => {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error("useChatContext must be used inside ChatProvider");
return ctx;
};

View file

@ -14,13 +14,17 @@ export default interface MessagesTable {
chatid: ColumnType<ChatsChatid, ChatsChatid, ChatsChatid>; chatid: ColumnType<ChatsChatid, ChatsChatid, ChatsChatid>;
message: ColumnType<string | null, string | null, string | null>; message: ColumnType<string, string, string>;
time: ColumnType<Date, Date | string, Date | string>; time: ColumnType<Date, Date | string, Date | string>;
isread: ColumnType<boolean, boolean | undefined, boolean>; isread: ColumnType<boolean, boolean | undefined, boolean>;
sender: ColumnType<UsersId, UsersId, UsersId>; sender: ColumnType<UsersId, UsersId, UsersId>;
reply_to_id: ColumnType<MessagesMessageid | null, MessagesMessageid | null, MessagesMessageid | null>;
is_forwarded: ColumnType<boolean, boolean | undefined, boolean>;
} }
export type Messages = Selectable<MessagesTable>; export type Messages = Selectable<MessagesTable>;

View file

@ -1,5 +1,7 @@
import { on } from "node:events"; import { on } from "node:events";
import { TRPCError, tracked } from "@trpc/server"; import { TRPCError, tracked } from "@trpc/server";
import { sql } from "kysely";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { MessagesMessageid } from "~/schemas/public/Messages"; import type { MessagesMessageid } from "~/schemas/public/Messages";
import { import {
@ -25,6 +27,7 @@ import {
zUserId, zUserId,
} from "~/server/utils/zod_types"; } from "~/server/utils/zod_types";
import { getKeydbClient, getSubClient } from "~/utils/keydb"; import { getKeydbClient, getSubClient } from "~/utils/keydb";
import { withUserColorStr } from "~/utils/kysely-helper";
const getChatChannel = (chatId: string) => `chat:${chatId}:messages`; const getChatChannel = (chatId: string) => `chat:${chatId}:messages`;
const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`; const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`;
@ -55,9 +58,17 @@ export const chatSSERouter = createTRPCRouter({
chatId: zChatId, chatId: zChatId,
cursor: zMessageId.nullish(), cursor: zMessageId.nullish(),
take: z.number().min(1).max(50).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 }) => { .query(async ({ input, ctx }) => {
const take = input.take ?? 30;
try { try {
const didUpdate = await setReadMessage({ const didUpdate = await setReadMessage({
chatId: input.chatId, chatId: input.chatId,
@ -75,17 +86,15 @@ export const chatSSERouter = createTRPCRouter({
} }
} }
const take = input.take ?? 20; if (input.targetMsgId) {
const cursor = input.cursor; // If a targetMsgId is provided, we want to fetch messages around that message
const msgs = await db
let qry = db .selectFrom("messages")
.selectFrom("messages") .innerJoin("users", "users.id", "messages.sender")
.innerJoin("users", "users.id", "messages.sender") .select((eb) => [
.select([ "messages.message",
"messages.message", "messages.messageid",
"messages.messageid", "messages.chatid",
"messages.chatid",
(eb) =>
eb eb
.cast<string>( .cast<string>(
eb.fn("to_char", [ eb.fn("to_char", [
@ -95,11 +104,91 @@ export const chatSSERouter = createTRPCRouter({
"text", "text",
) )
.as("time"), .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<string>(
eb.fn("to_char", [
"messages.time",
eb.val("YYYY-MM-DD HH24:MI:SS"),
]),
"text",
)
.as("time"),
"messages.sender", "messages.sender",
"messages.isread", "messages.isread",
"messages.is_forwarded",
"messages.reply_to_id",
"users.nome as sender_nome", "users.nome as sender_nome",
"users.isAdmin as sender_isAdmin", "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("users.nome", "is not", null)
.where("messages.chatid", "=", input.chatId) .where("messages.chatid", "=", input.chatId)
.orderBy("messages.messageid", "desc") .orderBy("messages.messageid", "desc")
@ -134,6 +223,8 @@ export const chatSSERouter = createTRPCRouter({
chatId: zChatId, chatId: zChatId,
message: z.string(), message: z.string(),
sender: zUserId, sender: zUserId,
is_forwarded: z.boolean().default(false),
reply_to_id: zMessageId.nullable().default(null),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@ -144,15 +235,33 @@ export const chatSSERouter = createTRPCRouter({
message: input.message, message: input.message,
sender: input.sender, sender: input.sender,
time: new Date(), time: new Date(),
is_forwarded: input.is_forwarded,
reply_to_id: input.reply_to_id,
}) })
.returningAll() .returningAll()
.executeTakeFirstOrThrow( .executeTakeFirstOrThrow(
() => new Error("Failed to insert message into database"), () => 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 const userInfos = await db
.selectFrom("users") .selectFrom("users")
.select(["nome as sender_nome", "isAdmin as sender_isAdmin"]) .select([
"nome as sender_nome",
"isAdmin as sender_isAdmin",
sql<string>`concat(username, '', id)`.as("color_str"),
])
.where("id", "=", input.sender) .where("id", "=", input.sender)
.executeTakeFirst(); .executeTakeFirst();
@ -165,6 +274,16 @@ export const chatSSERouter = createTRPCRouter({
const payload: Message = { const payload: Message = {
...newMessage, ...newMessage,
...userInfos, ...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(), time: newMessage.time.toISOString(),
}; };
@ -393,24 +512,39 @@ export const chatSSERouter = createTRPCRouter({
const newMessagesSinceLast = await db const newMessagesSinceLast = await db
.selectFrom("messages") .selectFrom("messages")
.innerJoin("users", "users.id", "messages.sender") .innerJoin("users", "users.id", "messages.sender")
.select([ .select((eb) => [
"messages.message", "messages.message",
"messages.messageid", "messages.messageid",
"messages.chatid", "messages.chatid",
(eb) => eb
eb .cast<string>(
.cast<string>( eb.fn("to_char", [
eb.fn("to_char", [ "messages.time",
"messages.time", eb.val("YYYY-MM-DD HH24:MI:SS"),
eb.val("YYYY-MM-DD HH24:MI:SS"), ]),
]), "text",
"text", )
) .as("time"),
.as("time"),
"messages.sender", "messages.sender",
"messages.isread", "messages.isread",
"messages.is_forwarded",
"messages.reply_to_id",
"users.nome as sender_nome", "users.nome as sender_nome",
"users.isAdmin as sender_isAdmin", "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.chatid", "=", input.chatId)
.where("messages.messageid", ">", lastMessageId) .where("messages.messageid", ">", lastMessageId)

View file

@ -10,7 +10,10 @@ import { getUserInfo } from "~/utils/kysely-helper";
export type ActiveChatsType = Pick<Chats, "chatid" | "created_at"> & export type ActiveChatsType = Pick<Chats, "chatid" | "created_at"> &
Pick<Users, "id" | "username" | "email" | "nome"> & { Pick<Users, "id" | "username" | "email" | "nome"> & {
lastmessage: Message | null; lastmessage: Pick<
Message,
"messageid" | "message" | "time" | "sender" | "isread"
> | null;
etichette: Etichette[]; etichette: Etichette[];
}; };
@ -31,24 +34,20 @@ export const getActiveInfos = async (): Promise<ActiveChatsType[]> => {
.selectFrom("messages") .selectFrom("messages")
.whereRef("messages.chatid", "=", "chats.chatid") .whereRef("messages.chatid", "=", "chats.chatid")
.innerJoin("users", "users.id", "messages.sender") .innerJoin("users", "users.id", "messages.sender")
.select([ .select((eb) => [
"messages.message", "messages.message",
"messages.messageid", "messages.messageid",
"messages.chatid", eb
(eb) => .cast<string>(
eb eb.fn("to_char", [
.cast<string>( "messages.time",
eb.fn("to_char", [ eb.val("YYYY-MM-DD HH24:MI:SS"),
"messages.time", ]),
eb.val("YYYY-MM-DD HH24:MI:SS"), "text",
]), )
"text", .as("time"),
)
.as("time"),
"messages.sender", "messages.sender",
"messages.isread", "messages.isread",
"users.nome as sender_nome",
"users.isAdmin as sender_isAdmin",
]) ])
.orderBy("messages.messageid", "desc") .orderBy("messages.messageid", "desc")
.limit(1), .limit(1),

View file

@ -100,13 +100,25 @@ export const zEventId = z.custom<EventQueueEventId>(
export const MessageDataSchema = z.object({ export const MessageDataSchema = z.object({
messageid: zMessageId, messageid: zMessageId,
message: z.string().nullable(), message: z.string(),
chatid: zChatId, chatid: zChatId,
time: z.string(), time: z.string(),
sender_nome: z.string(), sender_nome: z.string(),
sender_isAdmin: z.boolean(), sender_isAdmin: z.boolean(),
sender: zUserId, sender: zUserId,
isread: z.boolean(), 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( export const TypingDataSchema = z.array(
@ -114,7 +126,7 @@ export const TypingDataSchema = z.array(
userId: zUserId, userId: zUserId,
username: z.string(), username: z.string(),
}), }),
); );
export const SidebarUpdateSchema = z.object({ export const SidebarUpdateSchema = z.object({
chatId: zChatId, chatId: zChatId,

View file

@ -1,4 +1,4 @@
import { expressionBuilder } from "kysely"; import { expressionBuilder, sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import type Database from "~/schemas/Database"; import type Database from "~/schemas/Database";
@ -41,3 +41,7 @@ export function withVideos() {
// const date = new Date(str); // const date = new Date(str);
// return isValid(date) ? date : null; // return isValid(date) ? date : null;
// } // }
export function withUserColorStr() {
return sql<string>`concat(users.username, '', users.id)`.as("color_str");
}