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:
parent
1971251490
commit
98463da038
17 changed files with 905 additions and 552 deletions
12
apps/db/migrations/47_messages.up.sql
Normal file
12
apps/db/migrations/47_messages.up.sql
Normal 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;
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
chatUserData: ChatUserInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ChatBottombar({
|
|
||||||
chatId,
|
chatId,
|
||||||
userData,
|
session,
|
||||||
chatUserData,
|
inputRef,
|
||||||
}: ChatBottombarProps) {
|
replyTrg,
|
||||||
|
setReplyTrg,
|
||||||
|
messagesContainerRef,
|
||||||
|
} = useChatContext();
|
||||||
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 { mutate: addMutation } = api.chatSSE.sendMessage.useMutation({
|
||||||
|
onError: (err) => {
|
||||||
const handleThumbsUp = useCallback(() => {
|
console.error("Failed to send message", err);
|
||||||
addMutation({
|
},
|
||||||
chatId,
|
onMutate: () => {
|
||||||
message: "👍",
|
setReplyTrg(null);
|
||||||
sender: userData.id,
|
|
||||||
});
|
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}, [chatId, addMutation, userData]);
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (message.length !== 0) {
|
|
||||||
addMutation({
|
|
||||||
chatId,
|
|
||||||
message: message.trim(),
|
|
||||||
sender: userData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,12 +88,62 @@ 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="relative flex w-full flex-col border-t">
|
||||||
|
{showScrollDown && (
|
||||||
|
<Button
|
||||||
|
className="absolute -top-1/2 left-1/2 size-12 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-80"
|
||||||
|
onClick={handleJumpToBottom}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<ArrowDown className="size-6" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{replyTrg && (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="relative flex-1 rounded-lg border bg-muted p-2 text-sm">
|
||||||
|
<span>Rispondi:</span>
|
||||||
|
<div className="line-clamp-2 overflow-hidden text-ellipsis whitespace-pre-wrap text-sm">
|
||||||
|
{replyTrg.message}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="absolute top-1 right-1"
|
||||||
|
onClick={() => setReplyTrg(null)}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
|
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<ChatAttachments chatUserData={chatUserData} userData={userData} />
|
<ChatAttachments />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<AutosizeTextarea
|
<AutosizeTextarea
|
||||||
|
|
@ -138,9 +184,6 @@ export default function ChatBottombar({
|
||||||
locale={locale as "it" | "en"}
|
locale={locale as "it" | "en"}
|
||||||
onEmojiSelect={({ emoji }) => {
|
onEmojiSelect={({ emoji }) => {
|
||||||
setMessage(message + emoji);
|
setMessage(message + emoji);
|
||||||
// if (inputRef.current) {
|
|
||||||
// inputRef.current.textArea.focus();
|
|
||||||
// }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiPickerSearch
|
<EmojiPickerSearch
|
||||||
|
|
@ -168,5 +211,6 @@ export default function ChatBottombar({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
(
|
|
||||||
{ className, variant, layout, isLoading = false, children, ...props },
|
|
||||||
ref,
|
|
||||||
) => (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
chatBubbleMessageVariants({ className, layout, variant }),
|
chatBubbleMessageVariants({ className, variant }),
|
||||||
"wrap-break-word whitespace-pre-wrap",
|
"wrap-break-word whitespace-pre-wrap",
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{children}
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<MessageLoading />
|
|
||||||
</div>
|
</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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
|
||||||
userData: Session;
|
|
||||||
query: LiveChat["query"];
|
|
||||||
typingStatus: TypingData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChatList = ({
|
|
||||||
chatId,
|
chatId,
|
||||||
messages,
|
session,
|
||||||
userData,
|
liveChat,
|
||||||
query,
|
inputRef,
|
||||||
typingStatus,
|
setReplyTrg,
|
||||||
}: ChatListProps) => {
|
messagesContainerRef,
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
} = useChatContext();
|
||||||
|
const { messages, query, typingStatus } = liveChat;
|
||||||
|
|
||||||
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,26 +253,11 @@ export const ChatList = ({
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChatBubble variant={variant}>
|
|
||||||
{isNextSameSender ? (
|
|
||||||
<div className="size-6 shrink-0"></div>
|
|
||||||
) : (
|
|
||||||
<UserAvatar
|
|
||||||
className="size-6 shrink-0"
|
|
||||||
showInitials={false}
|
|
||||||
userId={message.sender}
|
|
||||||
username={message.sender_nome}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild disabled={!userData.isAdmin}>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{!isPreviousSameSender && (
|
{!isPreviousSameSender && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold text-sm",
|
"px-1.5 font-semibold text-sm",
|
||||||
variant === "sent" && "text-right",
|
isSender && "text-right",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
color: userColor,
|
color: userColor,
|
||||||
|
|
@ -244,44 +266,149 @@ export const ChatList = ({
|
||||||
{message.sender_nome}
|
{message.sender_nome}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ChatBubbleMessage
|
<ContextMenu>
|
||||||
className={cn(isNextSameSender && "rounded-lg", "")}
|
<ContextMenuTrigger asChild>
|
||||||
isLoading={false}
|
<motion.div
|
||||||
|
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 rounded-md px-1",
|
||||||
|
"data-[state=open]:bg-muted",
|
||||||
|
)}
|
||||||
|
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
|
||||||
|
id={message.messageid}
|
||||||
|
initial={{ opacity: 0, scale: 1, x: 0, y: 25 }}
|
||||||
|
layout
|
||||||
|
style={{ originX: 0.25, originY: 0.25 }}
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
bounce: 0.2,
|
||||||
|
duration: 0.2,
|
||||||
|
type: "spring",
|
||||||
|
},
|
||||||
|
opacity: { duration: 0.1 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<ChatBubble variant={isSender ? "sent" : "received"}>
|
||||||
|
<ChatBubbleMessage
|
||||||
|
className={cn(
|
||||||
|
"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>
|
||||||
|
</motion.div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<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>
|
||||||
|
)}
|
||||||
|
{session.isAdmin && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setMsgSelected(message);
|
||||||
|
handleDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Cancella
|
||||||
|
</ContextMenuItem>{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</ChatBubble>
|
</div>
|
||||||
</motion.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,14 +96,14 @@ export default function ChatTopbar({
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminPopover = ({
|
const AdminPopover = ({
|
||||||
chatUserData,
|
|
||||||
handleSwapUser,
|
handleSwapUser,
|
||||||
handleDeleteChat,
|
handleDeleteChat,
|
||||||
}: {
|
}: {
|
||||||
chatUserData: ChatUserInfo;
|
|
||||||
handleSwapUser: () => void;
|
handleSwapUser: () => void;
|
||||||
handleDeleteChat: () => void;
|
handleDeleteChat: () => void;
|
||||||
}) => (
|
}) => {
|
||||||
|
const { userinfo } = useChatContext();
|
||||||
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -133,7 +122,7 @@ const AdminPopover = ({
|
||||||
buttonVariants({ variant: "outline" }),
|
buttonVariants({ variant: "outline" }),
|
||||||
"justify-start gap-2",
|
"justify-start gap-2",
|
||||||
)}
|
)}
|
||||||
href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`}
|
href={`/area-riservata/admin/user-view/edit-user/${userinfo.id}`}
|
||||||
>
|
>
|
||||||
<UserCog /> Profilo
|
<UserCog /> Profilo
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -144,7 +133,7 @@ const AdminPopover = ({
|
||||||
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>
|
||||||
|
|
@ -154,7 +143,7 @@ const AdminPopover = ({
|
||||||
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>
|
||||||
|
|
@ -199,4 +188,5 @@ const AdminPopover = ({
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<ChatProvider values={{ liveChat, chatId, userinfo, session: userData }}>
|
||||||
<div className="flex h-full w-full flex-col justify-between">
|
<div className="flex h-full w-full flex-col justify-between">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<ChatTopbar
|
<ChatTopbar />
|
||||||
chatId={chatId}
|
|
||||||
chatUserData={userinfo}
|
|
||||||
userData={userData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<ChatList
|
<ChatList />
|
||||||
chatId={chatId}
|
<div className="shrink-0">
|
||||||
messages={liveChat.messages}
|
<ChatBottombar />
|
||||||
query={liveChat.query}
|
|
||||||
typingStatus={liveChat.typingStatus}
|
|
||||||
userData={userData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t">
|
|
||||||
<ChatBottombar
|
|
||||||
chatId={chatId}
|
|
||||||
chatUserData={userinfo}
|
|
||||||
userData={userData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ChatProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
56
apps/infoalloggi/src/providers/ChatProvider.tsx
Normal file
56
apps/infoalloggi/src/providers/ChatProvider.tsx
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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([
|
.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", [
|
||||||
|
|
@ -97,9 +106,89 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
.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.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.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("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,11 +512,10 @@ 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", [
|
||||||
|
|
@ -409,8 +527,24 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
.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)
|
||||||
|
|
|
||||||
|
|
@ -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,11 +34,9 @@ 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
|
eb
|
||||||
.cast<string>(
|
.cast<string>(
|
||||||
eb.fn("to_char", [
|
eb.fn("to_char", [
|
||||||
|
|
@ -47,8 +48,6 @@ export const getActiveInfos = async (): Promise<ActiveChatsType[]> => {
|
||||||
.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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue