feat(chat): implement chat bubble, list, sidebar, topbar, and loading components
- Added ChatBubble component for displaying individual chat messages with read status and timestamps. - Introduced ChatList component to manage and display a list of messages, including editing and deleting functionalities. - Created ChatSidebar for navigating between active chats and managing user interactions. - Developed ChatTopbar for displaying chat information and admin actions. - Implemented loading animation for messages with MessageLoading component. - Enhanced user experience with context menus for chat options and dialogs for editing and deleting messages.
This commit is contained in:
parent
f5d1530683
commit
1971251490
33 changed files with 2452 additions and 1873 deletions
15
apps/db/migrations/46_uuidv7.up.sql
Normal file
15
apps/db/migrations/46_uuidv7.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
BEGIN;
|
||||
|
||||
ALTER TABLE chats ALTER COLUMN chatid SET DEFAULT uuidv7();
|
||||
ALTER TABLE ordini ALTER COLUMN ordine_id SET DEFAULT uuidv7();
|
||||
ALTER TABLE potenziali_groups ALTER COLUMN id SET DEFAULT uuidv7();
|
||||
ALTER TABLE potenziali ALTER COLUMN id SET DEFAULT uuidv7();
|
||||
ALTER TABLE messages ALTER COLUMN messageid SET DEFAULT uuidv7();
|
||||
ALTER TABLE rinnovi ALTER COLUMN id SET DEFAULT uuidv7();
|
||||
ALTER TABLE user_invites ALTER COLUMN id SET DEFAULT uuidv7();
|
||||
ALTER TABLE users ALTER COLUMN id SET DEFAULT uuidv7();
|
||||
ALTER TABLE servizio ALTER COLUMN servizio_id SET DEFAULT uuidv7();
|
||||
ALTER TABLE users_storage ALTER COLUMN user_storage_id SET DEFAULT uuidv7();
|
||||
ALTER TABLE users_anagrafica ALTER COLUMN idanagrafica SET DEFAULT uuidv7();
|
||||
|
||||
COMMIT;
|
||||
195
apps/infoalloggi/src/components/chat/chat-attachments.tsx
Normal file
195
apps/infoalloggi/src/components/chat/chat-attachments.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { differenceInDays, differenceInMinutes, format } from "date-fns";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ExtIcon } from "~/components/area-riservata/allegati";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle,
|
||||
CredenzaTrigger,
|
||||
} from "~/components/custom_ui/credenza";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import { UploadComponent } from "~/components/upload_modal";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import type { ChatUserInfo } from "~/server/services/chat.service";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type ChatAttachmentsProps = {
|
||||
userData: Session;
|
||||
chatUserData: ChatUserInfo;
|
||||
};
|
||||
export const ChatAttachments = ({
|
||||
userData,
|
||||
chatUserData,
|
||||
}: ChatAttachmentsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: attachments,
|
||||
isLoading: attachmentsLoading,
|
||||
refetch,
|
||||
} = api.storage.retrieveUserFileData.useQuery(
|
||||
{ userId: chatUserData.id },
|
||||
{ refetchOnMount: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Credenza onOpenChange={(o) => o && refetch()}>
|
||||
<CredenzaTrigger asChild>
|
||||
<Button
|
||||
aria-label="Allegati Chat"
|
||||
className="relative size-9"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{attachments && attachments.length > 0 && (
|
||||
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 font-bold text-white text-xs">
|
||||
{attachments.length}
|
||||
</span>
|
||||
)}
|
||||
<Paperclip className="text-muted-foreground" size={20} />
|
||||
</Button>
|
||||
</CredenzaTrigger>
|
||||
<CredenzaContent className="max-h-[90vh] md:max-w-xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
|
||||
<CredenzaDescription className="sr-only">
|
||||
Chat Attachments
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody className="max-h-[80vh] space-y-5 overflow-auto pb-5 md:px-1">
|
||||
<UploadComponent
|
||||
isAdmin={userData.isAdmin}
|
||||
userId={chatUserData.id}
|
||||
/>
|
||||
|
||||
<div className="h-auto max-h-160 max-w-lg overflow-auto rounded-lg">
|
||||
{attachmentsLoading ? (
|
||||
<LoadingPage />
|
||||
) : (
|
||||
<>
|
||||
{!attachments || attachments.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-center font-semibold text-gray-500 text-lg">
|
||||
{t.allegati.nessun_allegato}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="px-2">
|
||||
<h3 className="font-semibold text-gray-700 text-lg">
|
||||
{t.allegati.allegati_recenti}
|
||||
</h3>
|
||||
{attachments?.map((file, idx) => {
|
||||
if (idx >= 3) return null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex flex-row items-center justify-between border-b py-1"
|
||||
key={`${file.originalName}-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
|
||||
idx
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
aria-label="Visualizza Allegato"
|
||||
href={`/area-riservata/allegato-view/${file.id}`}
|
||||
onTouchStart={() => console.log("touching")}
|
||||
target="_blank"
|
||||
>
|
||||
<Button
|
||||
className="h-8 truncate p-0 text-base has-[>svg]:px-0"
|
||||
variant="link"
|
||||
>
|
||||
<ExtIcon mimeType={file.mimeType} />
|
||||
<span className="max-w-[60vw] truncate">
|
||||
{file.originalName}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
key={new Date().toString()}
|
||||
>
|
||||
{TimeSince(new Date(file.uploadedAt))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
aria-label="Visualizza Allegati"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline",
|
||||
}),
|
||||
"relative w-full",
|
||||
)}
|
||||
href="/area-riservata/allegati"
|
||||
>
|
||||
<Paperclip /> {t.allegati.i_tuoi_allegati}
|
||||
{attachments && attachments.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
|
||||
{attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter className="flex flex-col items-center justify-between gap-2">
|
||||
<CredenzaClose asChild>
|
||||
<Button className="w-full">{t.chiudi}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeSince = (date: Date) => {
|
||||
const minsSince = Math.abs(differenceInMinutes(date, new Date()));
|
||||
const daysSince = Math.abs(differenceInDays(date, new Date()));
|
||||
|
||||
const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120];
|
||||
|
||||
if (daysSince === 0) {
|
||||
if (minsSince < 1) {
|
||||
return "ora";
|
||||
}
|
||||
|
||||
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
|
||||
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
|
||||
? curr
|
||||
: prev;
|
||||
});
|
||||
|
||||
if (closestToNow === 60) {
|
||||
return "~1 ora fa";
|
||||
}
|
||||
|
||||
if (closestToNow === 120 && minsSince < 121) {
|
||||
return "~2 ore fa";
|
||||
}
|
||||
|
||||
return `~${closestToNow} min fa`;
|
||||
}
|
||||
|
||||
if (daysSince === 1) {
|
||||
return "ieri";
|
||||
}
|
||||
|
||||
if (daysSince < 30) {
|
||||
return `${daysSince} giorni fa`;
|
||||
}
|
||||
|
||||
return format(date, "dd/MM/yyyy");
|
||||
};
|
||||
172
apps/infoalloggi/src/components/chat/chat-bottombar.tsx
Normal file
172
apps/infoalloggi/src/components/chat/chat-bottombar.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"use client";
|
||||
|
||||
import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChatAttachments } from "~/components/chat/chat-attachments";
|
||||
import {
|
||||
type AutosizeTextAreaRef,
|
||||
AutosizeTextarea,
|
||||
} from "~/components/custom_ui/autoResizeTextArea";
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerContent,
|
||||
EmojiPickerSearch,
|
||||
} from "~/components/custom_ui/emoji-picker";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
|
||||
import { useMediaQuery } from "~/hooks/use-media-query";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import type { ChatUserInfo } from "~/server/services/chat.service";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type ChatBottombarProps = {
|
||||
chatId: ChatsChatid;
|
||||
userData: Session;
|
||||
chatUserData: ChatUserInfo;
|
||||
};
|
||||
|
||||
export default function ChatBottombar({
|
||||
chatId,
|
||||
userData,
|
||||
chatUserData,
|
||||
}: ChatBottombarProps) {
|
||||
const { locale } = useTranslation();
|
||||
const [message, setMessage] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<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,
|
||||
});
|
||||
|
||||
setMessage("");
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.textArea.focus();
|
||||
inputRef.current.textArea.style.height = "auto";
|
||||
}
|
||||
}
|
||||
};
|
||||
const isTypingMutation = useThrottledIsTypingMutation({
|
||||
chatId,
|
||||
username: userData.username,
|
||||
userId: userData.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// update isTyping state
|
||||
isTypingMutation(isFocused && message.trim().length > 0);
|
||||
}, [isFocused, message, isTypingMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.textArea.focus();
|
||||
}
|
||||
}, []);
|
||||
const [openEmoji, setOpenEmoji] = useState(false);
|
||||
|
||||
const isMobile = useMediaQuery("(min-width: 426px)");
|
||||
return (
|
||||
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
|
||||
<div className="flex">
|
||||
<ChatAttachments chatUserData={chatUserData} userData={userData} />
|
||||
</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);
|
||||
// if (inputRef.current) {
|
||||
// inputRef.current.textArea.focus();
|
||||
// }
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
135
apps/infoalloggi/src/components/chat/chat-bubble.tsx
Normal file
135
apps/infoalloggi/src/components/chat/chat-bubble.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, CheckCheck } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import MessageLoading from "~/components/chat/message-loading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
// ChatBubble
|
||||
const chatBubbleVariant = cva(
|
||||
"flex gap-2 max-w-[85%] items-end relative group ",
|
||||
{
|
||||
defaultVariants: {
|
||||
layout: "default",
|
||||
variant: "received",
|
||||
},
|
||||
variants: {
|
||||
layout: {
|
||||
ai: "max-w-full w-full items-center",
|
||||
default: "",
|
||||
},
|
||||
variant: {
|
||||
received: "self-start",
|
||||
sent: "self-end flex-row-reverse",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ChatBubbleProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof chatBubbleVariant> {}
|
||||
|
||||
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
({ className, variant, layout, children, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
chatBubbleVariant({ className, layout, variant }),
|
||||
"group relative",
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, (child) =>
|
||||
React.isValidElement(child) && typeof child.type !== "string"
|
||||
? React.cloneElement(child, {
|
||||
layout,
|
||||
variant,
|
||||
} as Partial<React.ComponentProps<typeof child.type>>)
|
||||
: child,
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
ChatBubble.displayName = "ChatBubble";
|
||||
|
||||
type ChatBubbleReadStatusProps = {
|
||||
isread: boolean;
|
||||
};
|
||||
|
||||
export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => {
|
||||
if (isread) {
|
||||
return <CheckCheck className="mt-2 size-4 text-green-500" />;
|
||||
}
|
||||
return <Check className="mt-2 size-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
// ChatBubbleMessage
|
||||
const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", {
|
||||
defaultVariants: {
|
||||
layout: "default",
|
||||
variant: "received",
|
||||
},
|
||||
variants: {
|
||||
layout: {
|
||||
ai: "border-t w-full rounded-none bg-transparent",
|
||||
default: "",
|
||||
},
|
||||
variant: {
|
||||
received:
|
||||
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
|
||||
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface ChatBubbleMessageProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof chatBubbleMessageVariants> {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ChatBubbleMessage = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChatBubbleMessageProps
|
||||
>(
|
||||
(
|
||||
{ className, variant, layout, isLoading = false, children, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<div
|
||||
className={cn(
|
||||
chatBubbleMessageVariants({ className, layout, variant }),
|
||||
"wrap-break-word whitespace-pre-wrap",
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageLoading />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
ChatBubbleMessage.displayName = "ChatBubbleMessage";
|
||||
|
||||
// ChatBubbleTimestamp
|
||||
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 };
|
||||
470
apps/infoalloggi/src/components/chat/chat-list.tsx
Normal file
470
apps/infoalloggi/src/components/chat/chat-list.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ChatBubble,
|
||||
ChatBubbleMessage,
|
||||
ChatBubbleReadStatus,
|
||||
ChatBubbleTimestamp,
|
||||
} from "~/components/chat/chat-bubble";
|
||||
import LoadingButton from "~/components/custom_ui/loading-button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import Input from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { getUserColorV2, UserAvatar } from "~/components/user_avatar";
|
||||
import type { LiveChat } from "~/hooks/chatHooks";
|
||||
import { cn, getInitials } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { Message, TypingData } from "~/server/api/routers/chat_sse";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type ChatListProps = {
|
||||
chatId: ChatsChatid;
|
||||
messages: Message[];
|
||||
userData: Session;
|
||||
query: LiveChat["query"];
|
||||
typingStatus: TypingData;
|
||||
};
|
||||
|
||||
export const ChatList = ({
|
||||
chatId,
|
||||
messages,
|
||||
userData,
|
||||
query,
|
||||
typingStatus,
|
||||
}: ChatListProps) => {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [msgSelected, setMsgSelected] = useState<Message | null>(null);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
|
||||
const handleEditOpen = (status: boolean) => {
|
||||
setEditModalOpen(status);
|
||||
};
|
||||
const handleDeleteOpen = (status: boolean) => {
|
||||
setDeleteModalOpen(status);
|
||||
};
|
||||
|
||||
const { mutate: deleteMutation } = api.chatSSE.deleteMessage.useMutation({
|
||||
onError: () => {
|
||||
toast.error("Error deleting message", { id: "deleteMessage" });
|
||||
},
|
||||
onMutate: () => {
|
||||
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
|
||||
},
|
||||
});
|
||||
const { mutate: editMutation } = api.chatSSE.editMessage.useMutation({
|
||||
onError: () => {
|
||||
toast.error("Error editing message", { id: "editMessage" });
|
||||
},
|
||||
onMutate: () => {
|
||||
toast("Editing message...", { icon: "📝", id: "editMessage" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast("Message edited", { icon: "📝", id: "editMessage" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const raw_messageId = formData.get("message-id");
|
||||
const raw_message = formData.get("message-edit");
|
||||
|
||||
if (!raw_message || !raw_messageId) {
|
||||
return;
|
||||
}
|
||||
const messageId = raw_messageId.toString() as MessagesMessageid;
|
||||
const message = raw_message.toString();
|
||||
editMutation({
|
||||
chatId,
|
||||
message,
|
||||
messageId,
|
||||
});
|
||||
setEditModalOpen(false);
|
||||
},
|
||||
[editMutation],
|
||||
);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
messagesContainerRef.current &&
|
||||
!locked &&
|
||||
!query.isLoading &&
|
||||
!query.isFetching
|
||||
) {
|
||||
messagesContainerRef.current.scrollTop =
|
||||
messagesContainerRef.current.scrollHeight;
|
||||
}
|
||||
setLocked(false);
|
||||
}, [messages, typingStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn("flex h-full w-full flex-col overflow-y-auto p-4")}
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
<AnimatePresence key="chat-messages">
|
||||
{query.hasNextPage && (
|
||||
<LoadingButton
|
||||
aria-label="Load More Messages"
|
||||
className="mb-3"
|
||||
disabled={!query.hasNextPage || query.isFetchingNextPage}
|
||||
key="load-more"
|
||||
loading={query.isFetchingNextPage}
|
||||
onClick={async () => {
|
||||
setLocked(true);
|
||||
await query.fetchNextPage();
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{query.isFetchingNextPage
|
||||
? "Caricamento..."
|
||||
: "Carica messaggi precedenti"}
|
||||
</LoadingButton>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const variant = getMessageVariant(message.sender, userData.id);
|
||||
|
||||
const isNextSameSender =
|
||||
index + 1 < messages.length &&
|
||||
messages[index + 1]?.sender === message.sender;
|
||||
|
||||
const isPreviousSameSender =
|
||||
index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
|
||||
|
||||
const isPrevSameDay =
|
||||
index - 1 >= 0 &&
|
||||
// biome-ignore lint/style/noNonNullAssertion: <non lo so>
|
||||
new Date(messages[index - 1]!.time).toDateString() ===
|
||||
new Date(message.time).toDateString();
|
||||
|
||||
const userColor = getUserColorV2(
|
||||
`${getInitials(message.sender_nome)}${message.sender}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 pb-4",
|
||||
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}
|
||||
layout
|
||||
style={{ originX: 0.5, originY: 0.5 }}
|
||||
transition={{
|
||||
layout: {
|
||||
bounce: 0.3,
|
||||
duration: 0.3,
|
||||
type: "spring",
|
||||
},
|
||||
opacity: { duration: 0.1 },
|
||||
}}
|
||||
>
|
||||
{!isPrevSameDay && (
|
||||
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-muted-foreground text-xs">
|
||||
{new Date(message.time).toLocaleDateString("it", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})}
|
||||
</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 && (
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-sm",
|
||||
variant === "sent" && "text-right",
|
||||
)}
|
||||
style={{
|
||||
color: userColor,
|
||||
}}
|
||||
>
|
||||
{message.sender_nome}
|
||||
</p>
|
||||
)}
|
||||
<ChatBubbleMessage
|
||||
className={cn(isNextSameSender && "rounded-lg", "")}
|
||||
isLoading={false}
|
||||
>
|
||||
<span className="flex shrink break-all">
|
||||
{message.message}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{userData.id === message.sender && (
|
||||
<ChatBubbleReadStatus isread={message.isread} />
|
||||
)}
|
||||
{message.time && (
|
||||
<ChatBubbleTimestamp
|
||||
timestamp={new Date(
|
||||
message.time,
|
||||
).toLocaleString("it", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ChatBubbleMessage>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setMsgSelected(message);
|
||||
handleDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Cancella
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setMsgSelected(message);
|
||||
handleEditOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Modifica
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</ChatBubble>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Typers
|
||||
currentTypers={typingStatus}
|
||||
messageLenght={messages.length}
|
||||
userId={userData.id}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<EditMessageDialog
|
||||
handleEditSubmit={handleEditSubmit}
|
||||
msgEdit={msgSelected}
|
||||
onOpenChange={handleEditOpen}
|
||||
open={editModalOpen}
|
||||
/>
|
||||
<DeleteMessageDialog
|
||||
handleDeleteSubmit={(messageId) => {
|
||||
deleteMutation({ chatId, messageId });
|
||||
}}
|
||||
msgDelete={msgSelected}
|
||||
onOpenChange={handleDeleteOpen}
|
||||
open={deleteModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Typers = ({
|
||||
currentTypers,
|
||||
messageLenght,
|
||||
userId,
|
||||
}: {
|
||||
currentTypers: TypingData;
|
||||
messageLenght: number;
|
||||
userId: UsersId;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{currentTypers.map((typer) => {
|
||||
if (typer.userId === userId) return null;
|
||||
const variant = "received";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
className="flex flex-col gap-2 py-2"
|
||||
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
|
||||
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
|
||||
key={`typers${messageLenght}${typer.userId}`}
|
||||
layout
|
||||
style={{ originX: 0.5, originY: 0.5 }}
|
||||
transition={{
|
||||
layout: {
|
||||
bounce: 0.3,
|
||||
duration: 0.3,
|
||||
type: "spring",
|
||||
},
|
||||
opacity: { duration: 0.1 },
|
||||
}}
|
||||
>
|
||||
<ChatBubble variant={variant}>
|
||||
<UserAvatar
|
||||
className="size-6"
|
||||
showInitials={false}
|
||||
userId={typer.userId}
|
||||
username={typer.username}
|
||||
/>
|
||||
|
||||
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
|
||||
</ChatBubble>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EditMessageDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
msgEdit,
|
||||
handleEditSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (status: boolean) => void;
|
||||
msgEdit: Message | null;
|
||||
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.modifica}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Edit</DialogDescription>
|
||||
</DialogHeader>
|
||||
{msgEdit && (
|
||||
<form method="post" onSubmit={handleEditSubmit}>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="sr-only" htmlFor="id">
|
||||
ID
|
||||
</label>
|
||||
<Input
|
||||
className="hidden"
|
||||
id="id"
|
||||
name="message-id"
|
||||
readOnly
|
||||
type="text"
|
||||
value={msgEdit.messageid}
|
||||
/>
|
||||
<label className="sr-only" htmlFor="message-edit">
|
||||
Modifica messaggio
|
||||
</label>
|
||||
<Textarea
|
||||
className="mt-0 min-h-64"
|
||||
defaultValue={msgEdit.message || undefined}
|
||||
id="message-edit"
|
||||
name="message-edit"
|
||||
placeholder="Modifica messaggio"
|
||||
/>
|
||||
|
||||
<Button type="submit">{t.salva}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteMessageDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
msgDelete,
|
||||
handleDeleteSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (status: boolean) => void;
|
||||
msgDelete: Message | null;
|
||||
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{msgDelete && (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Questa azione non può essere annullata.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
aria-label="Delete Message"
|
||||
onClick={() => handleDeleteSubmit(msgDelete.messageid)}
|
||||
>
|
||||
{t.procedi}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageVariant = (messageName: string, selectedUserName: string) =>
|
||||
messageName !== selectedUserName ? "received" : "sent";
|
||||
458
apps/infoalloggi/src/components/chat/chat-sidebar.tsx
Normal file
458
apps/infoalloggi/src/components/chat/chat-sidebar.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import { Check, CheckCheck, Search, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Etichetta, EtichetteModal } from "~/components/etichette";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "~/components/ui/command";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu";
|
||||
import Input from "~/components/ui/input";
|
||||
import { UserAvatar } from "~/components/user_avatar";
|
||||
import { useMediaQuery } from "~/hooks/use-media-query";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { ActiveChatsType } from "~/server/controllers/chat.controller";
|
||||
import type { NonNullableUser } from "~/server/controllers/user.controller";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export const ChatSidebar = ({
|
||||
chatId,
|
||||
userId,
|
||||
}: {
|
||||
chatId: ChatsChatid | null;
|
||||
userId: UsersId;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
api.chatSSE.onSidebarUpdate.useSubscription(undefined, {
|
||||
onData() {
|
||||
void utils.chat.getActiveChats.invalidate();
|
||||
},
|
||||
});
|
||||
const { data: activeChats, isLoading: loadingActiveChats } =
|
||||
api.chat.getActiveChats.useQuery(undefined, {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const { data: inactiveUsers, isLoading: loadingInactiveUsers } =
|
||||
api.users.getActiveUsersWithoutChat.useQuery();
|
||||
|
||||
const router = useRouter();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false);
|
||||
|
||||
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.chat.getActiveChats.invalidate();
|
||||
await utils.users.getActiveUsersWithoutChat.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleChatSelection = useCallback(
|
||||
async (new_chatid: ChatsChatid) => {
|
||||
if (chatId === new_chatid) return;
|
||||
await router.push(`/area-riservata/admin/chats/${new_chatid}`);
|
||||
setSearchDialogOpen(false);
|
||||
},
|
||||
[chatId, router],
|
||||
);
|
||||
|
||||
if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />;
|
||||
if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>;
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-full flex-col gap-4 py-2 data-[collapsed=true]:p-2">
|
||||
<div className="mx-2 flex flex-col items-center justify-between gap-2 sm:flex-row sm:gap-0">
|
||||
{isDesktop ? (
|
||||
<div className="relative">
|
||||
<Search className="absolute top-[0.8rem] left-2 size-4 text-muted-foreground" />
|
||||
<label className="sr-only" htmlFor="search">
|
||||
Search Chats
|
||||
</label>
|
||||
<Input className="m-0 pl-8" id="search" placeholder="Search" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Open Search Dialog"
|
||||
className="size-8"
|
||||
onClick={() => setSearchDialogOpen(true)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search size={20} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<SearchUserComponent
|
||||
activeChats={activeChats}
|
||||
handleChatSelection={handleChatSelection}
|
||||
isOpen={searchDialogOpen}
|
||||
OpenChange={setSearchDialogOpen}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Open Add User Dialog"
|
||||
className="size-8"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<SquarePen size={20} />
|
||||
</Button>
|
||||
|
||||
<NewUserComponent
|
||||
inactiveUsers={inactiveUsers}
|
||||
isOpen={addDialogOpen}
|
||||
OpenChange={setAddDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="grid gap-1 rounded-lg group-data-[collapsed=true]:justify-center group-data-[collapsed=true]:px-2">
|
||||
{activeChats.map((chat) => (
|
||||
<ContextMenu key={chat.username}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Select Chat"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: !isDesktop ? "icon" : "xl",
|
||||
variant:
|
||||
chat.lastmessage &&
|
||||
!chat.lastmessage.isread &&
|
||||
chat.lastmessage.sender !== userId
|
||||
? "secondary"
|
||||
: "grey",
|
||||
}),
|
||||
|
||||
!isDesktop ? "p-1" : "justify-start gap-4",
|
||||
"h-fit w-full",
|
||||
)}
|
||||
onClick={() => handleChatSelection(chat.chatid)}
|
||||
>
|
||||
<UserAvatar
|
||||
className="size-10"
|
||||
userId={chat.id}
|
||||
username={chat.username}
|
||||
/>
|
||||
|
||||
{isDesktop && (
|
||||
<div className="flex h-full max-w-28 flex-col text-left align-top">
|
||||
<span>{chat.username}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chat.etichette.map((etichetta) => (
|
||||
<Etichetta
|
||||
className="text-xs"
|
||||
color_hex={etichetta.color_hex}
|
||||
key={etichetta.id_etichetta}
|
||||
title={etichetta.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{chat.lastmessage && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{chat.lastmessage.sender === userId ? "Tu: " : ""}
|
||||
{chat.lastmessage.message}
|
||||
</span>
|
||||
|
||||
{chat.lastmessage.sender === userId &&
|
||||
(chat.lastmessage.isread ? (
|
||||
<CheckCheck className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
))}
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{new Date(chat.lastmessage.time).toLocaleString(
|
||||
"it",
|
||||
{
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</ContextMenuTrigger>
|
||||
<ChatSidebarContextMenu
|
||||
chatid={chat.chatid}
|
||||
setDeleteModalOpen={setDeleteModalOpen}
|
||||
setEditEtichettaModalOpen={setEditEtichettaModalOpen}
|
||||
setSelectedChat={setSelectedChat}
|
||||
/>
|
||||
</ContextMenu>
|
||||
))}
|
||||
{selectedChat && (
|
||||
<>
|
||||
<AlertDialog
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
open={deleteModalOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Questa azione non può essere annullata. Questo eliminerà
|
||||
definitivamente la chat e rimuoverà i dati dai nostri
|
||||
server.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annulla</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
aria-label="Delete Chat"
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={() => {
|
||||
if (selectedChat) {
|
||||
deletechat({
|
||||
chatId: selectedChat,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continua
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<EtichetteModal
|
||||
chatId={selectedChat}
|
||||
onOpenChange={setEditEtichettaModalOpen}
|
||||
open={editEtichettaModalOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchUserComponent = ({
|
||||
isOpen,
|
||||
OpenChange,
|
||||
activeChats,
|
||||
handleChatSelection,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
OpenChange: (status: boolean) => void;
|
||||
activeChats: ActiveChatsType[];
|
||||
handleChatSelection: (chatId: ChatsChatid) => void;
|
||||
}) => {
|
||||
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>
|
||||
<CommandGroup
|
||||
className="pb-2"
|
||||
heading={activeChats.length > 0 ? "Suggerimenti" : undefined}
|
||||
>
|
||||
{activeChats?.map((chat) => (
|
||||
<CommandItem
|
||||
className="rounded-md"
|
||||
key={chat.id}
|
||||
onSelect={() => {
|
||||
handleChatSelection(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>
|
||||
);
|
||||
};
|
||||
|
||||
const NewUserComponent = ({
|
||||
isOpen,
|
||||
OpenChange,
|
||||
inactiveUsers,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
OpenChange: (status: boolean) => void;
|
||||
inactiveUsers: NonNullableUser[];
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutate: createNewChat } = api.chatSSE.createChat.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.chat.getActiveChats.invalidate();
|
||||
OpenChange(false);
|
||||
},
|
||||
});
|
||||
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>
|
||||
<CommandGroup
|
||||
className="pb-2"
|
||||
heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined}
|
||||
>
|
||||
{inactiveUsers?.map((user) => (
|
||||
<CommandItem
|
||||
className="rounded-md"
|
||||
key={user.id}
|
||||
onSelect={() => {
|
||||
createNewChat({
|
||||
userId: user.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
className="size-8"
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
/>
|
||||
|
||||
<span>
|
||||
<span className="block font-medium">{user.username}</span>
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatSidebarContextMenu = ({
|
||||
chatid,
|
||||
setSelectedChat,
|
||||
setDeleteModalOpen,
|
||||
setEditEtichettaModalOpen,
|
||||
}: {
|
||||
chatid: ChatsChatid;
|
||||
setSelectedChat: (chat: ChatsChatid | null) => void;
|
||||
setDeleteModalOpen: (status: boolean) => void;
|
||||
setEditEtichettaModalOpen: (status: boolean) => void;
|
||||
}) => {
|
||||
const { data: chatData } = api.chat.getChatInfobyId.useQuery({
|
||||
chatId: chatid,
|
||||
});
|
||||
if (!chatData) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
<ContextMenuItem asChild>
|
||||
<button
|
||||
aria-label="Edit Chat Labels"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedChat(chatid);
|
||||
setEditEtichettaModalOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<Link
|
||||
aria-label="View User Profile"
|
||||
href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`}
|
||||
target="_blank"
|
||||
>
|
||||
Profilo
|
||||
</Link>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem>
|
||||
<Link
|
||||
aria-label="View User Orders"
|
||||
href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`}
|
||||
target="_blank"
|
||||
>
|
||||
Ordini
|
||||
</Link>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<Link
|
||||
aria-label="View User Attachments"
|
||||
href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`}
|
||||
target="_blank"
|
||||
>
|
||||
Allegati
|
||||
</Link>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Altro</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
<ContextMenuItem
|
||||
aria-label="Edit Chat"
|
||||
className="cursor-pointer rounded-md bg-red-500 text-white hover:bg-red-500/90 focus:bg-red-500/90 focus:text-white"
|
||||
onClick={() => {
|
||||
setSelectedChat(chatid);
|
||||
setDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Elimina Chat
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
202
apps/infoalloggi/src/components/chat/chat-topbar.tsx
Normal file
202
apps/infoalloggi/src/components/chat/chat-topbar.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { Info, Paperclip, ShoppingBag, User, UserCog } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { EtichetteDisplayer } from "~/components/etichette";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { UserAvatar } from "~/components/user_avatar";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import type { ChatUserInfo } from "~/server/services/chat.service";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type ChatTopbarProps = {
|
||||
chatId: ChatsChatid;
|
||||
userData: Session;
|
||||
chatUserData: ChatUserInfo;
|
||||
};
|
||||
export default function ChatTopbar({
|
||||
chatId,
|
||||
userData,
|
||||
chatUserData,
|
||||
}: ChatTopbarProps) {
|
||||
const utils = api.useUtils();
|
||||
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.chat.getActiveChats.invalidate();
|
||||
await utils.users.getActiveUsersWithoutChat.invalidate();
|
||||
await router.push("/area-riservata/admin/chats/");
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { mutate: swap } = api.auth.swapUser.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Utente cambiato con successo");
|
||||
await router.push("/area-riservata/load-page");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteChat = useCallback(() => {
|
||||
deletechat({ chatId });
|
||||
}, [chatId, deletechat]);
|
||||
|
||||
const handleSwapUser = useCallback(() => {
|
||||
swap({ id: chatUserData.id });
|
||||
}, [chatUserData, swap]);
|
||||
|
||||
return (
|
||||
<div className="flex h-18 w-full items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{!userData.isAdmin ? (
|
||||
<Image
|
||||
alt="Infoalloggi logo"
|
||||
className="size-10 rounded-full"
|
||||
height={40}
|
||||
src={"/favicon/favicon.png"}
|
||||
width={40}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
className="size-10"
|
||||
userId={chatUserData.id}
|
||||
username={chatUserData.username}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-4">
|
||||
<span className="font-medium">
|
||||
{!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username}
|
||||
</span>
|
||||
{userData.isAdmin && <EtichetteDisplayer chatId={chatId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{userData.isAdmin && (
|
||||
<AdminPopover
|
||||
chatUserData={chatUserData}
|
||||
handleDeleteChat={handleDeleteChat}
|
||||
handleSwapUser={handleSwapUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AdminPopover = ({
|
||||
chatUserData,
|
||||
handleSwapUser,
|
||||
handleDeleteChat,
|
||||
}: {
|
||||
chatUserData: ChatUserInfo;
|
||||
handleSwapUser: () => void;
|
||||
handleDeleteChat: () => void;
|
||||
}) => (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
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">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
aria-label="Visualizza Profilo Utente"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"justify-start gap-2",
|
||||
)}
|
||||
href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`}
|
||||
>
|
||||
<UserCog /> Profilo
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
aria-label="Visualizza Ordini Utente"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"justify-start gap-2",
|
||||
)}
|
||||
href={`/area-riservata/admin/user-view/ordini/${chatUserData.id}`}
|
||||
>
|
||||
<ShoppingBag /> Ordini
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Visualizza Allegati Utente"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"justify-start gap-2",
|
||||
)}
|
||||
href={`/area-riservata/admin/user-view/allegati/${chatUserData.id}`}
|
||||
>
|
||||
<Paperclip /> Allegati
|
||||
</Link>
|
||||
<button
|
||||
aria-label="Cambia Utente"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"justify-start gap-2",
|
||||
)}
|
||||
onClick={handleSwapUser}
|
||||
type="button"
|
||||
>
|
||||
<User /> Login
|
||||
</button>
|
||||
<Separator />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
Cancella Chat
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Questa azione non può essere annullata. Questo eliminerà
|
||||
definitivamente la chat e rimuoverà i dati dai nostri server.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annulla</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
aria-label="Continua Eliminazione Chat"
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={handleDeleteChat}
|
||||
>
|
||||
Continua
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
50
apps/infoalloggi/src/components/chat/chat.tsx
Normal file
50
apps/infoalloggi/src/components/chat/chat.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { memo } from "react";
|
||||
import ChatBottombar from "~/components/chat/chat-bottombar";
|
||||
import { ChatList } from "~/components/chat/chat-list";
|
||||
import ChatTopbar from "~/components/chat/chat-topbar";
|
||||
import { useLiveChat } from "~/hooks/chatHooks";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const Chat = memo(
|
||||
({ chatId, userData }: { chatId: ChatsChatid; userData: Session }) => {
|
||||
const liveChat = useLiveChat(chatId, userData);
|
||||
const [userinfo] = api.chat.getUserInfosByChat.useSuspenseQuery({
|
||||
chatId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col justify-between">
|
||||
<div className="shrink-0">
|
||||
<ChatTopbar
|
||||
chatId={chatId}
|
||||
chatUserData={userinfo}
|
||||
userData={userData}
|
||||
/>
|
||||
</div>
|
||||
<ChatList
|
||||
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.chatId === nextProps.chatId &&
|
||||
prevProps.userData.id === nextProps.userData.id,
|
||||
);
|
||||
Chat.displayName = "Chat";
|
||||
|
||||
export default Chat;
|
||||
46
apps/infoalloggi/src/components/chat/message-loading.tsx
Normal file
46
apps/infoalloggi/src/components/chat/message-loading.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// @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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +1,8 @@
|
|||
import React from "react";
|
||||
import type React from "react";
|
||||
import { isColorDark } from "~/lib/color";
|
||||
import { cn, getHexColorFromString, getInitials } from "~/lib/utils";
|
||||
import { cn, getInitials } from "~/lib/utils";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
|
||||
const colorOptions = new Map<string, string>();
|
||||
|
||||
export const getUserColor = (str: string) => {
|
||||
if (colorOptions.has(str)) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: <exists>
|
||||
return colorOptions.get(str)!;
|
||||
}
|
||||
const usercolor = getHexColorFromString(str);
|
||||
colorOptions.set(str, usercolor);
|
||||
return usercolor;
|
||||
};
|
||||
/*
|
||||
export const UserAvatarOld = ({
|
||||
userId,
|
||||
username,
|
||||
className,
|
||||
}: {
|
||||
userId: UsersId;
|
||||
username?: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const userColor = getUserColor(userId);
|
||||
|
||||
return (
|
||||
<Avatar className={cn("size-20", className)}>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
color: colorIsDarkSimple(userColor) ? "#FFFFFF" : "#000000",
|
||||
}}
|
||||
>
|
||||
{username && getInitials(username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
*/
|
||||
/*
|
||||
export const UserAvatar2 = (
|
||||
props: {
|
||||
userId: UsersId;
|
||||
username?: string;
|
||||
} & Omit<
|
||||
FacehashProps,
|
||||
| "name"
|
||||
| "colors"
|
||||
| "initialsCount"
|
||||
| "intensity3d"
|
||||
| "variant"
|
||||
| "showInitial"
|
||||
>,
|
||||
) => {
|
||||
const { userId, username, ...rest } = props;
|
||||
const value = `${username && getInitials(username)}${userId}`;
|
||||
return (
|
||||
<Facehash
|
||||
{...rest}
|
||||
className={cn("size-20 rounded-full", rest.className)}
|
||||
colors={user_colors}
|
||||
initialsCount={2}
|
||||
intensity3d="none"
|
||||
name={value}
|
||||
showInitial={username !== undefined}
|
||||
variant="solid"
|
||||
/>
|
||||
);
|
||||
};
|
||||
*/
|
||||
const user_colors = [
|
||||
"#2D3436",
|
||||
"#535C68",
|
||||
|
|
@ -108,23 +40,49 @@ function stringHash(str: string): number {
|
|||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export const UserAvatar = (
|
||||
props: {
|
||||
userId: UsersId;
|
||||
username?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>,
|
||||
) => {
|
||||
const { userId, username, className, ...rest } = props;
|
||||
const value = `${username && getInitials(username)}${userId}`;
|
||||
|
||||
const colorIndex = React.useMemo(() => {
|
||||
export const getUserColorV2 = (value: string) => {
|
||||
const hash = stringHash(value);
|
||||
const colorsLength = user_colors.length ?? 1;
|
||||
const _colorIndex = hash % colorsLength;
|
||||
const colorIndex = hash % colorsLength;
|
||||
|
||||
return _colorIndex;
|
||||
}, [value]);
|
||||
const userColor = user_colors[colorIndex];
|
||||
return user_colors[colorIndex];
|
||||
};
|
||||
|
||||
type UserAvatarProps = {
|
||||
userId: UsersId;
|
||||
username: string;
|
||||
className?: string;
|
||||
showInitials?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
export const UserAvatar = ({
|
||||
userId,
|
||||
username,
|
||||
showInitials = true,
|
||||
className,
|
||||
...rest
|
||||
}: UserAvatarProps) => {
|
||||
if (!userId || !username) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={cn(
|
||||
"@container flex size-20 items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: "#fff5",
|
||||
color: "#oklch(0.24 0 0)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[40cqw] tracking-wide">
|
||||
{showInitials && "U"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const value = `${getInitials(username)}${userId}`;
|
||||
|
||||
const userColor = getUserColorV2(value);
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
|
|
@ -142,7 +100,7 @@ export const UserAvatar = (
|
|||
}}
|
||||
>
|
||||
<span className="text-[40cqw] tracking-wide">
|
||||
{username && getInitials(username)}
|
||||
{showInitials && getInitials(username)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,23 +2,11 @@ import { skipToken } from "@tanstack/react-query";
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { Message, TypingData } from "~/server/api/routers/chat_sse";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import type { ChatMessage } from "~/server/services/messages.service";
|
||||
import type { OnlineStatus, WhoIsTyping } from "~/server/sse";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type EditEvent = {
|
||||
eventType: "edit";
|
||||
data: { messageId: MessagesMessageid; message: string };
|
||||
};
|
||||
|
||||
type DeleteEvent = {
|
||||
eventType: "delete";
|
||||
data: MessagesMessageid;
|
||||
};
|
||||
|
||||
type MessageUpdate = EditEvent | DeleteEvent;
|
||||
|
||||
/**
|
||||
* Set isTyping with a throttle of 1s
|
||||
* Triggers immediately if state changes
|
||||
|
|
@ -26,11 +14,13 @@ type MessageUpdate = EditEvent | DeleteEvent;
|
|||
export function useThrottledIsTypingMutation({
|
||||
chatId,
|
||||
username,
|
||||
userId,
|
||||
}: {
|
||||
chatId: ChatsChatid;
|
||||
username: string;
|
||||
userId: UsersId;
|
||||
}) {
|
||||
const { mutate } = api.chatSSE.type.useMutation();
|
||||
const { mutate } = api.chatSSE.toggleTyping.useMutation();
|
||||
|
||||
return useMemo(() => {
|
||||
let state = false;
|
||||
|
|
@ -41,7 +31,7 @@ export function useThrottledIsTypingMutation({
|
|||
timeout = null;
|
||||
}
|
||||
|
||||
mutate({ chatId, typing: state, username });
|
||||
mutate({ chatId, isTyping: state, username, userId });
|
||||
}
|
||||
|
||||
return (nextState: boolean) => {
|
||||
|
|
@ -70,51 +60,42 @@ export type LiveChat = ReturnType<typeof useLiveChat>;
|
|||
export const useLiveChat = (chatId: ChatsChatid, userData: Session) => {
|
||||
//console.log("useLiveChat", chatId);
|
||||
|
||||
const [, query] = api.messagesSSE.infinite.useSuspenseInfiniteQuery(
|
||||
const [, query] = api.chatSSE.getChat.useSuspenseInfiniteQuery(
|
||||
{ chatId },
|
||||
{
|
||||
getNextPageParam: (d) => d.nextCursor,
|
||||
refetchOnMount: false,
|
||||
// No need to refetch as we have a subscription
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
return query.data ? query.data.pages.flatMap((page) => page.items) : [];
|
||||
});
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
);
|
||||
|
||||
/**
|
||||
* fn to add and dedupe new messages onto state
|
||||
*/
|
||||
const addMessages = useCallback((incoming?: ChatMessage[]) => {
|
||||
const addMessages = useCallback((incoming?: Message[]) => {
|
||||
setMessages((current) => {
|
||||
const map: Record<ChatMessage["messageid"], ChatMessage> = {};
|
||||
for (const msg of current ?? []) {
|
||||
map[msg.messageid] = msg;
|
||||
}
|
||||
for (const msg of incoming ?? []) {
|
||||
map[msg.messageid] = msg;
|
||||
}
|
||||
return Object.values(map).sort(
|
||||
(a, b) => a.time.getTime() - b.time.getTime(),
|
||||
const map: Record<Message["messageid"], Message> = {};
|
||||
for (const msg of current ?? []) map[msg.messageid] = msg;
|
||||
for (const msg of incoming ?? []) map[msg.messageid] = msg;
|
||||
return Object.values(map).sort((a, b) =>
|
||||
a.messageid.localeCompare(b.messageid),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Edit a message from the chat
|
||||
const updateMessage = useCallback(
|
||||
(msgId: MessagesMessageid, data: Partial<ChatMessage>) => {
|
||||
setMessages((current) => {
|
||||
const newMessages = current.map((msg) => {
|
||||
if (msg.messageid === msgId) {
|
||||
return { ...msg, ...data };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
return newMessages;
|
||||
});
|
||||
(msgId: MessagesMessageid, data: Partial<Message>) => {
|
||||
setMessages((current) =>
|
||||
current.map((msg) =>
|
||||
msg.messageid === msgId ? { ...msg, ...data } : msg,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -133,141 +114,72 @@ export const useLiveChat = (chatId: ChatsChatid, userData: Session) => {
|
|||
}, [query.data?.pages, addMessages]);
|
||||
|
||||
const [lastEventId, setLastEventId] = useState<
|
||||
// Query has not been run yet
|
||||
| false
|
||||
// Empty list
|
||||
| null
|
||||
// Event id
|
||||
| MessagesMessageid
|
||||
false | null | MessagesMessageid
|
||||
>(false);
|
||||
|
||||
if (messages && lastEventId === false) {
|
||||
// We should only set the lastEventId once, if the SSE-connection is lost, it will automatically reconnect and continue from the last event id
|
||||
// Changing this value will trigger a new subscription
|
||||
setLastEventId(messages.at(-1)?.messageid ?? null);
|
||||
}
|
||||
const { mutate: read } = api.messagesSSE.read.useMutation();
|
||||
|
||||
api.messagesSSE.onAdd.useSubscription(
|
||||
const { mutate: setRead } = api.chatSSE.setRead.useMutation();
|
||||
useEffect(() => {
|
||||
setRead({ chatId });
|
||||
}, [chatId]);
|
||||
|
||||
api.chatSSE.onMessage.useSubscription(
|
||||
lastEventId === false ? skipToken : { chatId, lastEventId },
|
||||
{
|
||||
onData(event) {
|
||||
const someoneElseInChat =
|
||||
onlineUsers.length > 1 &&
|
||||
onlineUsers.some(
|
||||
(user) =>
|
||||
user.userId !== userData.id && user.isAdmin !== userData.isAdmin,
|
||||
);
|
||||
addMessages([
|
||||
{ ...event.data, isread: event.data.isread || someoneElseInChat },
|
||||
]);
|
||||
read({ chatId });
|
||||
onData({ data }) {
|
||||
addMessages([data]);
|
||||
setRead({ chatId });
|
||||
},
|
||||
|
||||
onError(err) {
|
||||
console.error("onAdd Subscription client error:", err);
|
||||
|
||||
const lastMessageEventId = messages?.at(-1)?.messageid;
|
||||
if (lastMessageEventId) {
|
||||
// We've lost the connection, let's resubscribe from the last message
|
||||
setLastEventId(lastMessageEventId);
|
||||
}
|
||||
const lastId = messages.at(-1)?.messageid;
|
||||
if (lastId) setLastEventId(lastId);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
api.messagesSSE.onMessageEvent.useSubscription(
|
||||
const utils = api.useUtils();
|
||||
api.chatSSE.onMessageUpdates.useSubscription(
|
||||
{ chatId },
|
||||
{
|
||||
onData(data: { data: MessageUpdate }) {
|
||||
switch (data.data.eventType) {
|
||||
case "edit":
|
||||
handleEdit(data.data.data);
|
||||
break;
|
||||
case "delete":
|
||||
handleDelete(data.data.data);
|
||||
break;
|
||||
onData(event) {
|
||||
if (event.eventType === "edit") {
|
||||
updateMessage(event.data.messageId, { message: event.data.message });
|
||||
} else if (event.eventType === "delete") {
|
||||
deleteMessage(event.data);
|
||||
} else if (event.eventType === "read_receipt") {
|
||||
// Mark all messages sent by this user as read
|
||||
setMessages((current) =>
|
||||
current.map((msg) =>
|
||||
msg.sender === userData.id ? { ...msg, isread: true } : msg,
|
||||
),
|
||||
);
|
||||
// Invalidate sidebar query to update last message read status
|
||||
utils.chat.getActiveChats.invalidate();
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
console.error("Subscription error:", err);
|
||||
console.error("onMessageUpdates subscription error:", err);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleEdit = (data: {
|
||||
messageId: MessagesMessageid;
|
||||
message: string;
|
||||
}) => {
|
||||
updateMessage(data.messageId, { message: data.message });
|
||||
};
|
||||
const handleDelete = (data: MessagesMessageid) => {
|
||||
deleteMessage(data);
|
||||
};
|
||||
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineStatus[]>([]);
|
||||
const [typingStatus, setTypingStatus] = useState<WhoIsTyping[]>([]);
|
||||
|
||||
api.chatSSE.onChatEvent.useSubscription(
|
||||
{ chatId },
|
||||
const [typingStatus, setTypingStatus] = useState<TypingData>([]);
|
||||
api.chatSSE.onTyping.useSubscription(
|
||||
{ chatId, username: userData.username, userId: userData.id },
|
||||
{
|
||||
onComplete: () => {
|
||||
leave({ chatId });
|
||||
},
|
||||
onData: (data) => {
|
||||
switch (data.data.eventType) {
|
||||
case "online":
|
||||
console.log("Online event", data.data.data);
|
||||
handleOnlineEvent(data.data.data);
|
||||
break;
|
||||
case "typing":
|
||||
console.log("Typing event", data.data.data);
|
||||
handleTypingEvent(data.data.data);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onStarted: () => {
|
||||
enter({ chatId });
|
||||
onData(typers: TypingData) {
|
||||
setTypingStatus(typers.filter((n) => n.userId !== userData.id));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleOnlineEvent = (data: OnlineStatus[]) => {
|
||||
setOnlineUsers(data);
|
||||
|
||||
// Filter users who are not the current user and have a different `isAdmin` property
|
||||
const someoneElse = data.filter(
|
||||
(u) => u.userId !== userData.id && u.isAdmin !== userData.isAdmin,
|
||||
);
|
||||
if (someoneElse.length > 0) {
|
||||
// If there is someone else in chat and if thet are not the same usertype as me, update the read status of all messages
|
||||
setMessages((current) => {
|
||||
const newMessages = current.map((msg) => {
|
||||
return { ...msg, isread: true };
|
||||
});
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: enter } = api.chatSSE.enterChat.useMutation();
|
||||
const { mutate: leave } = api.chatSSE.leaveChat.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup: leave the chat when the component unmounts
|
||||
console.log("Leaving chat", chatId);
|
||||
leave({ chatId });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTypingEvent = (data: WhoIsTyping[]) => {
|
||||
setTypingStatus(data);
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
onlineUsers,
|
||||
query,
|
||||
typingStatus,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||
import { type RefinementCtx, z } from "zod/v4";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
|
||||
export const zStrongPassword = z.string().superRefine((password, ctx) => {
|
||||
const zStrongPassword = z.string().superRefine((password, ctx) => {
|
||||
const test = [/.{8,}/, /[0-9]/, /[a-z]/, /[A-Z]/].every((regex) =>
|
||||
regex.test(password),
|
||||
);
|
||||
|
|
@ -33,7 +33,7 @@ type PasswordManager = {
|
|||
maxStrength: number;
|
||||
};
|
||||
|
||||
export const useStrongPassword = (): PasswordManager => {
|
||||
const useStrongPassword = (): PasswordManager => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
|
|
@ -106,7 +106,7 @@ export const useStrongPassword = (): PasswordManager => {
|
|||
};
|
||||
};
|
||||
|
||||
export const zMildPassword = z.string().superRefine((password, ctx) => {
|
||||
const zMildPassword = z.string().superRefine((password, ctx) => {
|
||||
const test = [/.{6,}/, /[a-z]/, /[A-Z]/].every((regex) =>
|
||||
regex.test(password),
|
||||
);
|
||||
|
|
@ -119,7 +119,7 @@ export const zMildPassword = z.string().superRefine((password, ctx) => {
|
|||
}
|
||||
});
|
||||
|
||||
export const useMildPassword = (): PasswordManager => {
|
||||
const useMildPassword = (): PasswordManager => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
|
|
@ -189,6 +189,9 @@ export const useMildPassword = (): PasswordManager => {
|
|||
};
|
||||
};
|
||||
|
||||
export const zPassword = zMildPassword;
|
||||
const LEVEL = "strong";
|
||||
|
||||
export const usePassword = useMildPassword;
|
||||
export const zPassword = LEVEL === "strong" ? zStrongPassword : zMildPassword;
|
||||
|
||||
export const usePassword =
|
||||
LEVEL === "strong" ? useStrongPassword : useMildPassword;
|
||||
|
|
|
|||
|
|
@ -28,23 +28,23 @@ export function getInitials(name: string) {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function getHexColorFromString(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return "#000000"; // Return black for empty string
|
||||
// export function getHexColorFromString(str: string): string {
|
||||
// let hash = 0;
|
||||
// if (str.length === 0) return "#000000"; // Return black for empty string
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & 0xffffffff; // Ensure hash is a 32-bit integer
|
||||
}
|
||||
// for (let i = 0; i < str.length; i++) {
|
||||
// hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
// hash = hash & 0xffffffff; // Ensure hash is a 32-bit integer
|
||||
// }
|
||||
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 255;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
// let color = "#";
|
||||
// for (let i = 0; i < 3; i++) {
|
||||
// const value = (hash >> (i * 8)) & 255;
|
||||
// color += value.toString(16).padStart(2, "0");
|
||||
// }
|
||||
|
||||
return color;
|
||||
}
|
||||
// return color;
|
||||
// }
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat("it-IT", {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { api } from "~/utils/api";
|
|||
* Pagina di gestione chat per admin: /area-riservata/admin/chats
|
||||
*/
|
||||
const AdminChats: NextPageWithLayout = () => {
|
||||
const { status } = useSession();
|
||||
const { status, user } = useSession();
|
||||
|
||||
const { data: activeChats, isLoading: isLoadingActiveChats } =
|
||||
api.chat.getActiveChats.useQuery();
|
||||
|
|
@ -46,7 +46,7 @@ const AdminChats: NextPageWithLayout = () => {
|
|||
<div className="h-full flex-1 grow">
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="h-full border-r">
|
||||
<ChatSidebar chatId={null} />
|
||||
<ChatSidebar chatId={null} userId={user.id} />
|
||||
</div>
|
||||
<div className="w-full" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const ChatPage: NextPageWithLayout<ChatPageProps> = ({
|
|||
session,
|
||||
}: ChatPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
|
@ -45,10 +46,10 @@ const ChatPage: NextPageWithLayout<ChatPageProps> = ({
|
|||
<div className="h-full flex-1 grow">
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="h-full border-r">
|
||||
<ChatSidebar chatId={chatId} />
|
||||
<ChatSidebar chatId={chatId} userId={session.id} />
|
||||
</div>
|
||||
<Suspense fallback={<LoadingPage />}>
|
||||
<Chat chatId={chatId} userData={session} />
|
||||
<Chat chatId={chatId} key={chatId} userData={session} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,7 +83,7 @@ export const getServerSideProps = (async (context) => {
|
|||
|
||||
const helper = await TrpcAuthedFetchingIstance({ access_token });
|
||||
if (helper) {
|
||||
await helper.trpc.messagesSSE.infinite.prefetchInfinite({
|
||||
await helper.trpc.chatSSE.getChat.prefetchInfinite({
|
||||
chatId,
|
||||
});
|
||||
await helper.trpc.chat.getUserInfosByChat.prefetch({
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const getServerSideProps = (async (context) => {
|
|||
const helper = await TrpcAuthedFetchingIstance({ access_token });
|
||||
if (helper) {
|
||||
const chat = await helper.trpc.chat.getSessionChats.fetch();
|
||||
await helper.trpc.messagesSSE.infinite.prefetchInfinite({
|
||||
await helper.trpc.chatSSE.getChat.prefetchInfinite({
|
||||
chatId: chat.chatid,
|
||||
});
|
||||
await helper.trpc.chat.getUserInfosByChat.prefetch({
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "~/providers/SessionProvider";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type {
|
||||
ChatEvent,
|
||||
Message,
|
||||
SidebarUpdate,
|
||||
} from "~/server/api/routers/chat_sse2";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import { MessageDataSchema } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const TEST_CHAT_ID = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
export default function AdminChat() {
|
||||
const { status, user } = useSession();
|
||||
if (status === "LOADING") return <div>Loading...</div>;
|
||||
if (status !== "AUTHENTICATED") return null;
|
||||
return <Content session={user} />;
|
||||
}
|
||||
|
||||
const Content = ({ session }: { session: Session }) => {
|
||||
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputText, setInputText] = useState("");
|
||||
|
||||
const [sidebarEvents, setSidebarEvents] = useState<SidebarUpdate[]>([]);
|
||||
const [chatEvents, setChatEvents] = useState<ChatEvent[]>([]);
|
||||
const [activeTypers, setActiveTypers] = useState<string[]>([]);
|
||||
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { data: availableChats } = api.chatSSE2.listChats.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
// 1. Sidebar Subscription (Global)
|
||||
api.chatSSE2.onSidebarUpdate.useSubscription(undefined, {
|
||||
onData(update: SidebarUpdate) {
|
||||
setSidebarEvents((prev) => [update, ...prev].slice(0, 5));
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Fetch History (Only runs when a chat is selected)
|
||||
const { data: chatData, isSuccess } = api.chatSSE2.getChat.useQuery(
|
||||
{ chatId: selectedChat },
|
||||
{
|
||||
enabled: !!selectedChat,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChat && isSuccess && chatData) {
|
||||
setMessages(chatData);
|
||||
}
|
||||
}, [selectedChat, isSuccess, chatData]);
|
||||
|
||||
// 3. Chat Specific Subscription
|
||||
api.chatSSE2.onChatUpdate.useSubscription(
|
||||
{ chatId: selectedChat, username: session.username },
|
||||
{
|
||||
enabled: !!selectedChat,
|
||||
onData(update: ChatEvent) {
|
||||
setChatEvents((prev) => [update, ...prev].slice(0, 5));
|
||||
|
||||
if (update.eventType === "message") {
|
||||
const parsedMessage = MessageDataSchema.safeParse(update.data);
|
||||
if (parsedMessage.success) {
|
||||
setMessages((prev) => [...prev, parsedMessage.data]);
|
||||
} else {
|
||||
console.warn("Received invalid message data:", parsedMessage.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (update.eventType === "typing_update")
|
||||
setActiveTypers(
|
||||
update.data.filter((n: string) => n !== session.username),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sendMessage = api.chatSSE2.sendMessage.useMutation();
|
||||
const toggleTyping = api.chatSSE2.toggleTyping.useMutation();
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputText || !selectedChat) return;
|
||||
|
||||
sendMessage.mutate({
|
||||
chatId: selectedChat,
|
||||
message: inputText,
|
||||
sender: session.id, // Prod: Admin ID
|
||||
});
|
||||
|
||||
setInputText("");
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
toggleTyping.mutate({
|
||||
chatId: selectedChat,
|
||||
username: session.username,
|
||||
isTyping: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputText(e.target.value);
|
||||
if (!selectedChat) return;
|
||||
|
||||
toggleTyping.mutate({
|
||||
chatId: selectedChat,
|
||||
username: session.username,
|
||||
isTyping: true,
|
||||
});
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
toggleTyping.mutate({
|
||||
chatId: selectedChat,
|
||||
username: session.username,
|
||||
isTyping: false,
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
padding: "20px",
|
||||
fontFamily: "sans-serif",
|
||||
height: "90vh",
|
||||
}}
|
||||
>
|
||||
{/* Sidebar View */}
|
||||
<div
|
||||
style={{
|
||||
width: "250px",
|
||||
borderRight: "1px solid #ccc",
|
||||
paddingRight: "20px",
|
||||
}}
|
||||
>
|
||||
<h2>Admin Inbox</h2>
|
||||
|
||||
{availableChats?.length === 0 && <div>No active chats</div>}
|
||||
{availableChats?.map((chat) => (
|
||||
<button
|
||||
key={chat.chatid}
|
||||
onClick={() => setSelectedChat(chat.chatid)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor:
|
||||
selectedChat === chat.chatid ? "#e0f7fa" : "#fff",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{chat.chatid}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "10px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<h4>Global Sidebar Events:</h4>
|
||||
{sidebarEvents.map((ev, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
|
||||
key={i}
|
||||
style={{ borderBottom: "1px solid #ddd", marginBottom: "5px" }}
|
||||
>
|
||||
<strong>{ev.senderName}:</strong> {ev.lastMessage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat View */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{!selectedChat ? (
|
||||
<div style={{ margin: "auto", color: "gray" }}>
|
||||
Select a chat to begin
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h3>Chatting with: {TEST_CHAT_ID.split("-")[0]}...</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
border: "1px solid #eee",
|
||||
padding: "10px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.messageid}
|
||||
style={{
|
||||
margin: "5px 0",
|
||||
textAlign: m.sender_isAdmin ? "right" : "left",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: m.sender_isAdmin ? "#007bff" : "#e9ecef",
|
||||
color: m.sender_isAdmin ? "white" : "black",
|
||||
padding: "5px 10px",
|
||||
borderRadius: "15px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{m.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{activeTypers.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
color: "gray",
|
||||
fontSize: "0.8em",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{activeTypers.join(", ")} is typing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSend}
|
||||
style={{ display: "flex", gap: "10px" }}
|
||||
>
|
||||
<input
|
||||
onChange={handleInputChange}
|
||||
placeholder="Admin reply..."
|
||||
style={{ flex: 1, padding: "8px" }}
|
||||
value={inputText}
|
||||
/>
|
||||
<button type="submit">Reply</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "380px",
|
||||
marginTop: "20px",
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#00ff00",
|
||||
padding: "10px",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h4>Raw Chat Events (KeyDB)</h4>
|
||||
<button onClick={() => setChatEvents([])} type="button">
|
||||
Clear
|
||||
</button>
|
||||
<pre style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(chatEvents, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "~/providers/SessionProvider";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { ChatEvent, Message } from "~/server/api/routers/chat_sse2";
|
||||
import type { Session } from "~/server/api/trpc";
|
||||
import { MessageDataSchema } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
// Hardcoded for testing the connection
|
||||
const TEST_CHAT_ID = "85b6c0fb-2997-4b9a-9cac-ebfc1886927b" as ChatsChatid;
|
||||
|
||||
export default function GuestChat() {
|
||||
const { status, user } = useSession();
|
||||
if (status === "LOADING") return <div>Loading...</div>;
|
||||
if (status !== "AUTHENTICATED") return null;
|
||||
return <Content session={user} />;
|
||||
}
|
||||
|
||||
const Content = ({ session }: { session: Session }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [activeTypers, setActiveTypers] = useState<string[]>([]);
|
||||
const [debugLog, setDebugLog] = useState<ChatEvent[]>([]);
|
||||
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { data: historyData, isSuccess } = api.chatSSE2.getChat.useQuery(
|
||||
{ chatId: TEST_CHAT_ID },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
setMessages(historyData);
|
||||
}
|
||||
}, [isSuccess, historyData]);
|
||||
|
||||
// 2. Mutations
|
||||
const sendMessage = api.chatSSE2.sendMessage.useMutation();
|
||||
const toggleTyping = api.chatSSE2.toggleTyping.useMutation();
|
||||
|
||||
// 3. The Live Subscription
|
||||
api.chatSSE2.onChatUpdate.useSubscription(
|
||||
{ chatId: TEST_CHAT_ID, username: session.username },
|
||||
{
|
||||
onData(update: ChatEvent) {
|
||||
// Log raw event for debugging
|
||||
setDebugLog((prev) => [update, ...prev].slice(0, 10));
|
||||
|
||||
if (update.eventType === "message") {
|
||||
const parsedMessage = MessageDataSchema.safeParse(update.data);
|
||||
if (parsedMessage.success) {
|
||||
setMessages((prev) => [...prev, parsedMessage.data]);
|
||||
} else {
|
||||
console.warn("Received invalid message data:", parsedMessage.error);
|
||||
}
|
||||
}
|
||||
if (update.eventType === "typing_update") {
|
||||
setActiveTypers(
|
||||
update.data.filter((name: string) => name !== session.username),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
console.error("Subscription Error:", err);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Typing logic (Heartbeat)
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputText(e.target.value);
|
||||
|
||||
// Ping typing status
|
||||
toggleTyping.mutate({
|
||||
chatId: TEST_CHAT_ID,
|
||||
username: session.username,
|
||||
isTyping: true,
|
||||
});
|
||||
|
||||
// Clear previous timeout
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
|
||||
// If user stops typing for 2 seconds, send isTyping: false
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
toggleTyping.mutate({
|
||||
chatId: TEST_CHAT_ID,
|
||||
username: session.username,
|
||||
isTyping: false,
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputText) return;
|
||||
|
||||
sendMessage.mutate({
|
||||
chatId: TEST_CHAT_ID,
|
||||
message: inputText,
|
||||
sender: session.id,
|
||||
});
|
||||
|
||||
setInputText("");
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
toggleTyping.mutate({
|
||||
chatId: TEST_CHAT_ID,
|
||||
username: session.username,
|
||||
isTyping: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
padding: "20px",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* UI Panel */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "1px solid #ccc",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h2>Guest View (Logged in as: {session.username})</h2>
|
||||
<div
|
||||
style={{
|
||||
height: "80vh",
|
||||
overflowY: "auto",
|
||||
border: "1px solid #eee",
|
||||
padding: "10px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.messageid}
|
||||
style={{
|
||||
margin: "5px 0",
|
||||
color: m.sender_isAdmin ? "blue" : "black",
|
||||
}}
|
||||
>
|
||||
<strong>{m.sender_nome || "You"}:</strong> {m.message}
|
||||
</div>
|
||||
))}
|
||||
{activeTypers.length > 0 && (
|
||||
<div
|
||||
style={{ color: "gray", fontSize: "0.8em", fontStyle: "italic" }}
|
||||
>
|
||||
{activeTypers.join(", ")}{" "}
|
||||
{activeTypers.length === 1 ? "is" : "are"} typing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSend} style={{ display: "flex", gap: "10px" }}>
|
||||
<input
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
style={{ flex: 1, padding: "8px" }}
|
||||
value={inputText}
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#00ff00",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<h3>📡 KeyDB Debug Panel</h3>
|
||||
<button onClick={() => setDebugLog([])} type="button">
|
||||
Clear
|
||||
</button>
|
||||
<hr style={{ borderColor: "#333" }} />
|
||||
<h4>Last 10 Events:</h4>
|
||||
<pre style={{ fontSize: "12px", whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(debugLog, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,12 +3,10 @@ import { authRouter } from "~/server/api/routers/auth";
|
|||
import { banlistRouter } from "~/server/api/routers/banlist";
|
||||
import { chatRouter } from "~/server/api/routers/chat";
|
||||
import { chatSSERouter } from "~/server/api/routers/chat_sse";
|
||||
import { chatSSE2Router } from "~/server/api/routers/chat_sse2";
|
||||
import { comunicazioniRouter } from "~/server/api/routers/comunicazioni";
|
||||
import { eventQueueRouter } from "~/server/api/routers/event_queue";
|
||||
import { fattureRouter } from "~/server/api/routers/fatture";
|
||||
import { intrestsRouter } from "~/server/api/routers/interests";
|
||||
import { messagesSSERouter } from "~/server/api/routers/messages_sse";
|
||||
import { pagamentiRouter } from "~/server/api/routers/pagamenti";
|
||||
import { prezziarioRouter } from "~/server/api/routers/prezziario";
|
||||
import { servizioRouter } from "~/server/api/routers/servizio";
|
||||
|
|
@ -36,12 +34,10 @@ export const appRouter = createTRPCRouter({
|
|||
banlist: banlistRouter,
|
||||
chat: chatRouter,
|
||||
chatSSE: chatSSERouter,
|
||||
chatSSE2: chatSSE2Router,
|
||||
comunicazioni: comunicazioniRouter,
|
||||
eventQueue: eventQueueRouter,
|
||||
fatture: fattureRouter,
|
||||
intrests: intrestsRouter,
|
||||
messagesSSE: messagesSSERouter,
|
||||
pagamenti: pagamentiRouter,
|
||||
prezziario: prezziarioRouter,
|
||||
servizio: servizioRouter,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
import z from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
getComuni,
|
||||
getNazioni,
|
||||
getProvincie,
|
||||
searchComuniByCatasto,
|
||||
} from "~/server/controllers/catasto.controller";
|
||||
|
||||
export const catastoRouter = createTRPCRouter({
|
||||
getComuni: publicProcedure.query(async () => {
|
||||
return await getComuni();
|
||||
}),
|
||||
searchComuniByCatasto: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
catasto: z.string().array(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await searchComuniByCatasto(input.catasto);
|
||||
}),
|
||||
|
||||
getProvincie: publicProcedure.query(async () => {
|
||||
return await getProvincie();
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export const chatRouter = createTRPCRouter({
|
|||
return await deleteChatHandler({ chatId: input.chatId });
|
||||
}),
|
||||
|
||||
getActiveChats: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getActiveInfos({ userId: ctx.session.id });
|
||||
getActiveChats: adminProcedure.query(async () => {
|
||||
return await getActiveInfos();
|
||||
}),
|
||||
getChatEtichette: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
|
|
|
|||
|
|
@ -1,20 +1,44 @@
|
|||
import { on } from "node:events";
|
||||
import { TRPCError, tracked } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import {
|
||||
addTyping,
|
||||
getCurrentlyTyping,
|
||||
removeStaleTyping,
|
||||
removeTyping,
|
||||
} from "~/server/controllers/chat.controller";
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { add } from "~/server/services/chat.service";
|
||||
import { ee, type OnlineStatus } from "~/server/sse";
|
||||
import { zChatId, zUserId } from "~/server/utils/zod_types";
|
||||
import { getKeydbClient } from "~/utils/keydb";
|
||||
//IDEA forse cambiare in semplice interval polling invece di sse
|
||||
import {
|
||||
editMessage,
|
||||
setReadMessage,
|
||||
} from "~/server/services/messages.service";
|
||||
import { zAsyncIterable } from "~/server/utils/zAsyncIterable";
|
||||
import {
|
||||
MessageDataSchema,
|
||||
MessageUpdateEventSchema,
|
||||
SidebarUpdateSchema,
|
||||
TypingDataSchema,
|
||||
zChatId,
|
||||
zMessageId,
|
||||
zUserId,
|
||||
} from "~/server/utils/zod_types";
|
||||
import { getKeydbClient, getSubClient } from "~/utils/keydb";
|
||||
|
||||
const getChatChannel = (chatId: string) => `chat:${chatId}:messages`;
|
||||
const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`;
|
||||
const getEventsChannel = (chatId: string) => `chat:${chatId}:events`;
|
||||
const getTypingKey = (chatId: string) => `typing:${chatId}`;
|
||||
|
||||
const SIDEBAR_CHANNEL = "sidebar_updates";
|
||||
|
||||
type SidebarUpdate = z.infer<typeof SidebarUpdateSchema>;
|
||||
export type Message = z.infer<typeof MessageDataSchema>;
|
||||
export type TypingData = z.infer<typeof TypingDataSchema>;
|
||||
|
||||
export const chatSSERouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
createChat: protectedProcedure
|
||||
.input(z.object({ userId: zUserId }))
|
||||
.mutation(async ({ input }) => {
|
||||
const newChat = await add({
|
||||
|
|
@ -25,143 +49,493 @@ export const chatSSERouter = createTRPCRouter({
|
|||
return newChat.chatid;
|
||||
}),
|
||||
|
||||
enterChat: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const chatKey = `chat:${input.chatId}:onlineUsers`;
|
||||
const keydb = getKeydbClient();
|
||||
|
||||
if (!keydb) {
|
||||
console.warn(
|
||||
"KeyDB client is not initialized. Skipping online status.",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
// Add the user to the Redis set
|
||||
await keydb.sadd(
|
||||
chatKey,
|
||||
JSON.stringify({
|
||||
isAdmin: ctx.session.isAdmin,
|
||||
nome: ctx.session.nome,
|
||||
userId: ctx.session.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const onlineUsers = await keydb.smembers(chatKey);
|
||||
const parsedUsers = onlineUsers.map(
|
||||
(user) => JSON.parse(user) as OnlineStatus,
|
||||
);
|
||||
ee.emit("chatUpdates", input.chatId, {
|
||||
data: parsedUsers,
|
||||
eventType: "online",
|
||||
});
|
||||
return parsedUsers;
|
||||
}),
|
||||
|
||||
leaveChat: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const chatKey = `chat:${input.chatId}:onlineUsers`;
|
||||
const keydb = getKeydbClient();
|
||||
if (!keydb) {
|
||||
console.warn(
|
||||
"KeyDB client is not initialized. Skipping online status.",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
// Remove the user from the Redis set
|
||||
await keydb.srem(
|
||||
chatKey,
|
||||
JSON.stringify({
|
||||
isAdmin: ctx.session.isAdmin,
|
||||
nome: ctx.session.nome,
|
||||
userId: ctx.session.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const onlineUsers = await keydb.smembers(chatKey);
|
||||
const parsedUsers = onlineUsers.map(
|
||||
(user) => JSON.parse(user) as OnlineStatus,
|
||||
);
|
||||
ee.emit("chatUpdates", input.chatId, {
|
||||
data: parsedUsers,
|
||||
eventType: "online",
|
||||
});
|
||||
return parsedUsers;
|
||||
}),
|
||||
// list: publicProcedure.query(async () => {
|
||||
// return await db
|
||||
// .selectFrom("chats")
|
||||
// .select([
|
||||
// "chats.chatid",
|
||||
// "chats.created_at",
|
||||
// "chats.user_ref",
|
||||
// getMessagesArray(),
|
||||
// ])
|
||||
// .innerJoin("users", "users.id", "chats.user_ref")
|
||||
// .select(["users.username", "users.email", "users.nome"])
|
||||
// .orderBy("chats.created_at", "desc")
|
||||
// .execute();
|
||||
// }),
|
||||
|
||||
onChatEvent: protectedProcedure
|
||||
getChat: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
cursor: zMessageId.nullish(),
|
||||
take: z.number().min(1).max(50).nullish(),
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const iterable = ee.toIterable("chatUpdates", {
|
||||
signal,
|
||||
const didUpdate = await setReadMessage({
|
||||
chatId: input.chatId,
|
||||
db,
|
||||
reader: ctx.session.id,
|
||||
});
|
||||
|
||||
for await (const [chatId, data] of iterable) {
|
||||
if (chatId === input.chatId) {
|
||||
yield tracked(chatId, data);
|
||||
if (didUpdate) {
|
||||
const kdb = getKeydbClient();
|
||||
if (kdb) {
|
||||
await kdb.publish(
|
||||
getEventsChannel(input.chatId),
|
||||
JSON.stringify({ eventType: "read_receipt", data: null }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") return;
|
||||
console.error("Error in onChatEvent subscription:", e);
|
||||
}
|
||||
}),
|
||||
|
||||
type: protectedProcedure
|
||||
.input(
|
||||
z.object({ chatId: zChatId, typing: z.boolean(), username: z.string() }),
|
||||
const take = input.take ?? 20;
|
||||
const cursor = input.cursor;
|
||||
|
||||
let qry = db
|
||||
.selectFrom("messages")
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"messages.message",
|
||||
"messages.messageid",
|
||||
"messages.chatid",
|
||||
(eb) =>
|
||||
eb
|
||||
.cast<string>(
|
||||
eb.fn("to_char", [
|
||||
"messages.time",
|
||||
eb.val("YYYY-MM-DD HH24:MI:SS"),
|
||||
]),
|
||||
"text",
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await removeStaleTyping(input.chatId);
|
||||
.as("time"),
|
||||
"messages.sender",
|
||||
"messages.isread",
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("users.nome", "is not", null)
|
||||
.where("messages.chatid", "=", input.chatId)
|
||||
.orderBy("messages.messageid", "desc")
|
||||
.limit(take + 1);
|
||||
|
||||
if (input.typing) {
|
||||
// Add the user to the set
|
||||
await addTyping({
|
||||
chatId: input.chatId,
|
||||
userId: ctx.session.id,
|
||||
username: input.username,
|
||||
});
|
||||
} else {
|
||||
await removeTyping({
|
||||
chatId: input.chatId,
|
||||
userId: ctx.session.id,
|
||||
username: input.username,
|
||||
});
|
||||
if (cursor) {
|
||||
qry = qry.where("messages.messageid", "<=", cursor);
|
||||
}
|
||||
|
||||
ee.emit("chatUpdates", input.chatId, {
|
||||
data: await getCurrentlyTyping(input.chatId),
|
||||
eventType: "typing",
|
||||
});
|
||||
return true;
|
||||
const page = await qry.execute();
|
||||
|
||||
const items = page.reverse();
|
||||
let nextCursor: typeof cursor | null = null;
|
||||
if (items.length > take) {
|
||||
const prev = items.shift();
|
||||
nextCursor = prev?.messageid ?? null;
|
||||
}
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"An error occurred while processing the typing event: " +
|
||||
(e as Error).message,
|
||||
message: `Error while fetching messages: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
sendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
message: z.string(),
|
||||
sender: zUserId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const newMessage = await db
|
||||
.insertInto("messages")
|
||||
.values({
|
||||
chatid: input.chatId,
|
||||
message: input.message,
|
||||
sender: input.sender,
|
||||
time: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow(
|
||||
() => new Error("Failed to insert message into database"),
|
||||
);
|
||||
|
||||
const userInfos = await db
|
||||
.selectFrom("users")
|
||||
.select(["nome as sender_nome", "isAdmin as sender_isAdmin"])
|
||||
.where("id", "=", input.sender)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!userInfos) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found or no name",
|
||||
});
|
||||
}
|
||||
const payload: Message = {
|
||||
...newMessage,
|
||||
...userInfos,
|
||||
time: newMessage.time.toISOString(),
|
||||
};
|
||||
|
||||
const kdb = getKeydbClient();
|
||||
const msgChannel = getChatChannel(input.chatId);
|
||||
const typingChannel = getTypingChannel(input.chatId);
|
||||
const typingKey = getTypingKey(input.chatId);
|
||||
if (kdb) {
|
||||
await kdb.publish(msgChannel, JSON.stringify(payload));
|
||||
const member = JSON.stringify({
|
||||
userId: input.sender,
|
||||
username: userInfos.sender_nome,
|
||||
});
|
||||
await kdb.zrem(typingKey, member);
|
||||
const typingList = await kdb.zrange(typingKey, 0, -1);
|
||||
|
||||
await kdb.publish(
|
||||
typingChannel,
|
||||
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
||||
);
|
||||
const sidebarPayload: SidebarUpdate = {
|
||||
chatId: input.chatId,
|
||||
lastMessage: input.message,
|
||||
senderName: userInfos.sender_nome,
|
||||
};
|
||||
await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload));
|
||||
}
|
||||
|
||||
return payload;
|
||||
}),
|
||||
deleteMessage: adminProcedure
|
||||
.input(z.object({ chatId: zChatId, messageId: zMessageId }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await db
|
||||
.deleteFrom("messages")
|
||||
.where("messageid", "=", input.messageId)
|
||||
.execute();
|
||||
|
||||
const kdb = getKeydbClient();
|
||||
if (kdb) {
|
||||
await kdb.publish(
|
||||
getEventsChannel(input.chatId),
|
||||
JSON.stringify({ eventType: "delete", data: input.messageId }),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete message:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete message",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
editMessage: adminProcedure
|
||||
.input(
|
||||
z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await editMessage({
|
||||
db,
|
||||
message: input.message,
|
||||
messageId: input.messageId,
|
||||
});
|
||||
|
||||
const kdb = getKeydbClient();
|
||||
if (kdb) {
|
||||
await kdb.publish(
|
||||
getEventsChannel(input.chatId),
|
||||
JSON.stringify({
|
||||
eventType: "edit",
|
||||
data: { messageId: input.messageId, message: input.message },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to edit message:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to edit message",
|
||||
});
|
||||
}
|
||||
}),
|
||||
onMessageUpdates: publicProcedure
|
||||
.input(z.object({ chatId: zChatId.nullable() }))
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: MessageUpdateEventSchema,
|
||||
tracked: false,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
if (!input.chatId) return;
|
||||
const sub = getSubClient();
|
||||
if (!sub) return; // Build-time safety
|
||||
|
||||
const channel = getEventsChannel(input.chatId);
|
||||
|
||||
try {
|
||||
await sub.subscribe(channel);
|
||||
const iterable = on(sub, "message");
|
||||
|
||||
for await (const [chan, message] of iterable) {
|
||||
if (chan === channel) {
|
||||
if (signal?.aborted) break; // Extra safety check
|
||||
const parsed = MessageUpdateEventSchema.safeParse(
|
||||
JSON.parse(message),
|
||||
);
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
} else {
|
||||
console.warn(
|
||||
"Received invalid message update event:",
|
||||
parsed.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sub.unsubscribe(channel);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
onSidebarUpdate: publicProcedure
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: SidebarUpdateSchema,
|
||||
tracked: false,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* () {
|
||||
const sub = getSubClient();
|
||||
if (!sub) return;
|
||||
try {
|
||||
await sub.subscribe(SIDEBAR_CHANNEL);
|
||||
const iterable = on(sub, "message");
|
||||
for await (const [_chan, message] of iterable) {
|
||||
const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message));
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
} else {
|
||||
console.warn("Received invalid message event:", parsed.error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sub.unsubscribe(SIDEBAR_CHANNEL);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
|
||||
toggleTyping: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
isTyping: z.boolean(),
|
||||
userId: zUserId,
|
||||
username: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const kdb = getKeydbClient();
|
||||
if (!kdb) return;
|
||||
|
||||
const key = getTypingKey(input.chatId);
|
||||
const now = Date.now();
|
||||
const member = JSON.stringify({
|
||||
userId: input.userId,
|
||||
username: input.username,
|
||||
});
|
||||
if (input.isTyping) {
|
||||
await kdb.zadd(key, now, member);
|
||||
} else {
|
||||
await kdb.zrem(key, member);
|
||||
}
|
||||
|
||||
// Cleanup: Remove users who haven't pinged in 6 seconds
|
||||
await kdb.zremrangebyscore(key, "-inf", now - 6000);
|
||||
|
||||
const typingList = await kdb.zrange(key, 0, -1);
|
||||
|
||||
await kdb.publish(
|
||||
getTypingChannel(input.chatId),
|
||||
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
onMessage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId.nullable(),
|
||||
lastEventId: z.string().nullish(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: MessageDataSchema,
|
||||
tracked: true,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
if (!input.chatId) return;
|
||||
const sub = getSubClient();
|
||||
if (!sub) return; // Build-time safety
|
||||
|
||||
const channel = getChatChannel(input.chatId);
|
||||
|
||||
try {
|
||||
// ON CONNECT
|
||||
await sub.subscribe(channel);
|
||||
const iterable = on(sub, "message");
|
||||
|
||||
let lastMessageId: MessagesMessageid | null =
|
||||
(() => {
|
||||
if (!input.lastEventId) return null;
|
||||
|
||||
const parsed = zMessageId.safeParse(input.lastEventId);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
})() ?? null;
|
||||
|
||||
const newMessagesSinceLast = await db
|
||||
.selectFrom("messages")
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"messages.message",
|
||||
"messages.messageid",
|
||||
"messages.chatid",
|
||||
(eb) =>
|
||||
eb
|
||||
.cast<string>(
|
||||
eb.fn("to_char", [
|
||||
"messages.time",
|
||||
eb.val("YYYY-MM-DD HH24:MI:SS"),
|
||||
]),
|
||||
"text",
|
||||
)
|
||||
.as("time"),
|
||||
"messages.sender",
|
||||
"messages.isread",
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("messages.chatid", "=", input.chatId)
|
||||
.where("messages.messageid", ">", lastMessageId)
|
||||
.orderBy("messages.messageid", "asc")
|
||||
.execute();
|
||||
|
||||
function* maybeYield(msg: Message) {
|
||||
if (msg.chatid !== input.chatId) {
|
||||
// ignore posts from other channels - the event emitter can emit from other channels
|
||||
return;
|
||||
}
|
||||
if (lastMessageId && msg.messageid <= lastMessageId) {
|
||||
// ignore posts that we've already sent - happens if there is a race condition between the query and the event emitter
|
||||
return;
|
||||
}
|
||||
|
||||
yield tracked(msg.messageid, msg);
|
||||
|
||||
// update the cursor so that we don't send this post again
|
||||
lastMessageId = msg.messageid;
|
||||
}
|
||||
for (const msg of newMessagesSinceLast) {
|
||||
yield* maybeYield(msg);
|
||||
}
|
||||
|
||||
for await (const [chan, message] of iterable) {
|
||||
if (chan === channel) {
|
||||
if (signal?.aborted) break; // Extra safety check
|
||||
const parsed = MessageDataSchema.safeParse(JSON.parse(message));
|
||||
if (parsed.success) {
|
||||
yield* maybeYield(parsed.data);
|
||||
} else {
|
||||
console.warn("Received invalid chat event:", parsed.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sub.unsubscribe(channel);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
onTyping: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId.nullable(),
|
||||
username: z.string(),
|
||||
userId: zUserId,
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: TypingDataSchema,
|
||||
tracked: false,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
if (!input.chatId) return;
|
||||
const sub = getSubClient();
|
||||
const kdb = getKeydbClient();
|
||||
if (!sub || !kdb) return; // Build-time safety
|
||||
|
||||
const channel = getTypingChannel(input.chatId);
|
||||
const typingKey = getTypingKey(input.chatId);
|
||||
|
||||
try {
|
||||
// ON CONNECT
|
||||
await sub.subscribe(channel);
|
||||
const iterable = on(sub, "message");
|
||||
|
||||
const current = await kdb.zrange(typingKey, 0, -1);
|
||||
|
||||
const parsed = TypingDataSchema.safeParse(
|
||||
current.map((m) => JSON.parse(m)),
|
||||
);
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
}
|
||||
|
||||
for await (const [chan, message] of iterable) {
|
||||
if (chan === channel) {
|
||||
if (signal?.aborted) break; // Extra safety check
|
||||
const parsed = TypingDataSchema.safeParse(JSON.parse(message));
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
} else {
|
||||
console.warn("Received invalid chat event:", parsed.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// ON DISCONNECT (Tab closed, refreshed, or network dropped)
|
||||
const member = JSON.stringify({
|
||||
userId: input.userId,
|
||||
username: input.username,
|
||||
});
|
||||
await kdb.zrem(typingKey, member);
|
||||
const remainingT = await kdb.zrange(typingKey, 0, -1);
|
||||
await kdb.publish(
|
||||
channel,
|
||||
JSON.stringify(remainingT.map((m) => JSON.parse(m))),
|
||||
);
|
||||
|
||||
await sub.unsubscribe(channel);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
setRead: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const didUpdate = await setReadMessage({
|
||||
chatId: input.chatId,
|
||||
db,
|
||||
reader: ctx.session.id,
|
||||
});
|
||||
|
||||
if (didUpdate) {
|
||||
const kdb = getKeydbClient();
|
||||
if (kdb) {
|
||||
await kdb.publish(
|
||||
getEventsChannel(input.chatId),
|
||||
JSON.stringify({ eventType: "read_receipt", data: null }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,246 +0,0 @@
|
|||
import { on } from "node:events";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { add } from "~/server/services/chat.service";
|
||||
import { zAsyncIterable } from "~/server/utils/zAsyncIterable";
|
||||
import {
|
||||
ChatEventSchema,
|
||||
type MessageDataSchema,
|
||||
type MessageEventSchema,
|
||||
SidebarUpdateSchema,
|
||||
zChatId,
|
||||
zUserId,
|
||||
} from "~/server/utils/zod_types";
|
||||
import { getKeydbClient, getSubClient } from "~/utils/keydb";
|
||||
|
||||
const getChatChannel = (chatId: string) => `chat:${chatId}`;
|
||||
const getTypingKey = (chatId: string) => `typing:${chatId}`;
|
||||
const SIDEBAR_CHANNEL = "sidebar_updates";
|
||||
|
||||
export type SidebarUpdate = z.infer<typeof SidebarUpdateSchema>;
|
||||
export type Message = z.infer<typeof MessageDataSchema>;
|
||||
type MessageEvent = z.infer<typeof MessageEventSchema>;
|
||||
export type ChatEvent = z.infer<typeof ChatEventSchema>;
|
||||
|
||||
export const chatSSE2Router = createTRPCRouter({
|
||||
listChats: protectedProcedure.query(async () => {
|
||||
return await db.selectFrom("chats").selectAll().execute();
|
||||
}),
|
||||
createChat: protectedProcedure
|
||||
.input(z.object({ userId: zUserId }))
|
||||
.mutation(async ({ input }) => {
|
||||
const newChat = await add({
|
||||
db,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
return newChat.chatid;
|
||||
}),
|
||||
getChat: publicProcedure
|
||||
.input(z.object({ chatId: zChatId.nullable() }))
|
||||
.query(async ({ input }) => {
|
||||
if (!input.chatId) return;
|
||||
const data = await db
|
||||
.selectFrom("messages")
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"messages.message",
|
||||
"messages.messageid",
|
||||
"messages.time",
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("messages.chatid", "=", input.chatId)
|
||||
.orderBy("messages.time", "asc")
|
||||
.execute();
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
time: item.time.toISOString(),
|
||||
}));
|
||||
}),
|
||||
sendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
message: z.string(),
|
||||
sender: zUserId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const newMessage = await db
|
||||
.insertInto("messages")
|
||||
.values({
|
||||
chatid: input.chatId,
|
||||
message: input.message,
|
||||
sender: input.sender,
|
||||
time: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow(
|
||||
() => new Error("Failed to insert message into database"),
|
||||
);
|
||||
|
||||
const userInfos = await db
|
||||
.selectFrom("users")
|
||||
.select(["nome as sender_nome", "isAdmin as sender_isAdmin"])
|
||||
.where("id", "=", input.sender)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!userInfos) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found or no name",
|
||||
});
|
||||
}
|
||||
const payload: MessageEvent = {
|
||||
data: {
|
||||
...newMessage,
|
||||
...userInfos,
|
||||
time: newMessage.time.toISOString(),
|
||||
},
|
||||
eventType: "message",
|
||||
};
|
||||
|
||||
const kdb = getKeydbClient();
|
||||
if (kdb) {
|
||||
await kdb.publish(
|
||||
getChatChannel(input.chatId),
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
|
||||
await kdb.zrem(getTypingKey(input.chatId), userInfos.sender_nome);
|
||||
const typingList = await kdb.zrange(getTypingKey(input.chatId), 0, -1);
|
||||
|
||||
await kdb.publish(
|
||||
getChatChannel(input.chatId),
|
||||
JSON.stringify({
|
||||
eventType: "typing_update",
|
||||
data: typingList,
|
||||
}),
|
||||
);
|
||||
const sidebarPayload: SidebarUpdate = {
|
||||
chatId: input.chatId,
|
||||
lastMessage: input.message,
|
||||
senderName: userInfos.sender_nome,
|
||||
};
|
||||
await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload));
|
||||
}
|
||||
|
||||
return payload;
|
||||
}),
|
||||
onSidebarUpdate: publicProcedure
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: SidebarUpdateSchema,
|
||||
tracked: false,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* () {
|
||||
const sub = getSubClient();
|
||||
if (!sub) return;
|
||||
try {
|
||||
await sub.subscribe(SIDEBAR_CHANNEL);
|
||||
const iterable = on(sub, "message");
|
||||
for await (const [_chan, message] of iterable) {
|
||||
const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message));
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
} else {
|
||||
console.warn("Received invalid message event:", parsed.error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sub.unsubscribe(SIDEBAR_CHANNEL);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
|
||||
toggleTyping: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
isTyping: z.boolean(),
|
||||
username: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const kdb = getKeydbClient();
|
||||
if (!kdb) return;
|
||||
|
||||
const key = getTypingKey(input.chatId);
|
||||
const now = Date.now();
|
||||
|
||||
if (input.isTyping) {
|
||||
await kdb.zadd(key, now, input.username);
|
||||
} else {
|
||||
await kdb.zrem(key, input.username);
|
||||
}
|
||||
|
||||
// Cleanup: Remove users who haven't pinged in 6 seconds
|
||||
await kdb.zremrangebyscore(key, "-inf", now - 6000);
|
||||
|
||||
const typingList = await kdb.zrange(key, 0, -1);
|
||||
|
||||
await kdb.publish(
|
||||
getChatChannel(input.chatId),
|
||||
JSON.stringify({
|
||||
eventType: "typing_update",
|
||||
data: typingList,
|
||||
}),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
onChatUpdate: publicProcedure
|
||||
.input(z.object({ chatId: zChatId.nullable(), username: z.string() }))
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
yield: ChatEventSchema,
|
||||
tracked: false,
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
if (!input.chatId) return;
|
||||
const sub = getSubClient();
|
||||
const kdb = getKeydbClient();
|
||||
if (!sub || !kdb) return; // Build-time safety
|
||||
|
||||
const channel = getChatChannel(input.chatId);
|
||||
const typingKey = getTypingKey(input.chatId);
|
||||
|
||||
try {
|
||||
// ON CONNECT
|
||||
await sub.subscribe(channel);
|
||||
const iterable = on(sub, "message");
|
||||
|
||||
for await (const [chan, message] of iterable) {
|
||||
if (chan === channel) {
|
||||
if (signal?.aborted) break; // Extra safety check
|
||||
const parsed = ChatEventSchema.safeParse(JSON.parse(message));
|
||||
if (parsed.success) {
|
||||
yield parsed.data;
|
||||
} else {
|
||||
console.warn("Received invalid chat event:", parsed.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// ON DISCONNECT (Tab closed, refreshed, or network dropped)
|
||||
await kdb.zrem(typingKey, input.username);
|
||||
const remainingT = await kdb.zrange(typingKey, 0, -1);
|
||||
await kdb.publish(
|
||||
channel,
|
||||
JSON.stringify({ eventType: "typing_update", data: remainingT }),
|
||||
);
|
||||
|
||||
await sub.unsubscribe(channel);
|
||||
await sub.quit();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
import { TRPCError, tracked } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import {
|
||||
getCurrentlyTyping,
|
||||
removeStaleTyping,
|
||||
removeTyping,
|
||||
} from "~/server/controllers/chat.controller";
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
type ChatMessage,
|
||||
editMessage,
|
||||
setReadMessage,
|
||||
} from "~/server/services/messages.service";
|
||||
import { ee, type MessageUpdate } from "~/server/sse";
|
||||
import { zAsyncIterable } from "~/server/utils/sse_zod";
|
||||
import { zChatId, zMessageId, zUserId } from "~/server/utils/zod_types";
|
||||
|
||||
export const messagesSSERouter = createTRPCRouter({
|
||||
add: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
message: z.string(),
|
||||
sender: zUserId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const newMessage = await db
|
||||
.insertInto("messages")
|
||||
.values({
|
||||
chatid: input.chatId,
|
||||
message: input.message,
|
||||
sender: input.sender,
|
||||
time: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow(
|
||||
() => new Error("Failed to insert message into database"),
|
||||
);
|
||||
|
||||
const userInfos = await db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("id", "=", input.sender)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!userInfos) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found or no name",
|
||||
});
|
||||
}
|
||||
|
||||
await removeTyping({
|
||||
chatId: input.chatId,
|
||||
userId: input.sender,
|
||||
username: userInfos.sender_nome,
|
||||
});
|
||||
await removeStaleTyping(input.chatId);
|
||||
|
||||
ee.emit("chatUpdates", input.chatId, {
|
||||
data: await getCurrentlyTyping(input.chatId),
|
||||
eventType: "typing",
|
||||
});
|
||||
|
||||
ee.emit("add", input.chatId, {
|
||||
...newMessage,
|
||||
...userInfos,
|
||||
});
|
||||
|
||||
return { ...newMessage, ...userInfos };
|
||||
}),
|
||||
delete: adminProcedure
|
||||
.input(z.object({ chatId: zChatId, messageId: zMessageId }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db
|
||||
.deleteFrom("messages")
|
||||
.where("messageid", "=", input.messageId)
|
||||
.execute();
|
||||
|
||||
ee.emit("messageUpdates", input.chatId, {
|
||||
data: input.messageId,
|
||||
eventType: "delete",
|
||||
});
|
||||
}),
|
||||
|
||||
edit: adminProcedure
|
||||
.input(
|
||||
z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
await editMessage({
|
||||
db,
|
||||
message: input.message,
|
||||
messageId: input.messageId,
|
||||
});
|
||||
|
||||
ee.emit("messageUpdates", input.chatId, {
|
||||
data: {
|
||||
message: input.message,
|
||||
messageId: input.messageId,
|
||||
},
|
||||
eventType: "edit",
|
||||
});
|
||||
}),
|
||||
infinite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
cursor: z.date().nullish(),
|
||||
take: z.number().min(1).max(50).nullish(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
await setReadMessage({
|
||||
chatId: input.chatId,
|
||||
db,
|
||||
reader: ctx.session.id,
|
||||
});
|
||||
|
||||
const take = input.take ?? 20;
|
||||
const cursor = input.cursor;
|
||||
|
||||
let qry = db
|
||||
.selectFrom("messages")
|
||||
.selectAll()
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("users.nome", "is not", null)
|
||||
.where("messages.chatid", "=", input.chatId)
|
||||
.orderBy("messages.time", "desc")
|
||||
.limit(take + 1);
|
||||
|
||||
if (cursor) {
|
||||
qry = qry.where("messages.time", "<=", cursor);
|
||||
}
|
||||
|
||||
const page = await qry.execute();
|
||||
|
||||
const items = page.reverse();
|
||||
let nextCursor: typeof cursor | null = null;
|
||||
if (items.length > take) {
|
||||
const prev = items.shift();
|
||||
// biome-ignore lint/style/noNonNullAssertion: <ok>
|
||||
nextCursor = prev!.time;
|
||||
}
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error while fetching messages: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
onAdd: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chatId: zChatId,
|
||||
// lastEventId is the last event id that the client has received
|
||||
// On the first call, it will be whatever was passed in the initial setup
|
||||
// If the client reconnects, it will be the last event id that the client received
|
||||
lastEventId: zMessageId.nullish(),
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal, ctx }) {
|
||||
try {
|
||||
// We start by subscribing to the event emitter so that we don't miss any new events while fetching
|
||||
const iterable = ee.toIterable("add", {
|
||||
signal,
|
||||
});
|
||||
|
||||
// Fetch the last message createdAt based on the last event id
|
||||
let lastMessageCreatedAt = await (async () => {
|
||||
const lastEventId = input.lastEventId;
|
||||
console.log("lastEventId", lastEventId);
|
||||
if (!lastEventId) return null;
|
||||
|
||||
const messageById = await db
|
||||
.selectFrom("messages")
|
||||
.selectAll()
|
||||
.where("messageid", "=", lastEventId)
|
||||
.executeTakeFirstOrThrow(() => {
|
||||
throw new Error(`Message not found - messageId: ${lastEventId}`);
|
||||
});
|
||||
|
||||
return messageById?.time ?? null;
|
||||
})();
|
||||
|
||||
let qry = db
|
||||
.selectFrom("messages")
|
||||
.selectAll()
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.where("messages.chatid", "=", input.chatId)
|
||||
.orderBy("messages.time", "asc");
|
||||
|
||||
if (lastMessageCreatedAt) {
|
||||
qry = qry.where("messages.time", ">", lastMessageCreatedAt);
|
||||
}
|
||||
|
||||
const newMessagesSinceLastMessage = await qry.execute();
|
||||
|
||||
await setReadMessage({
|
||||
chatId: input.chatId,
|
||||
db,
|
||||
reader: ctx.session.id,
|
||||
});
|
||||
|
||||
function* maybeYield(msg: ChatMessage) {
|
||||
if (msg.chatid !== input.chatId) {
|
||||
// ignore messages from other channels - the event emitter can emit from other channels
|
||||
return;
|
||||
}
|
||||
if (lastMessageCreatedAt && msg.time <= lastMessageCreatedAt) {
|
||||
// ignore messages that we've already sent - happens if there is a race condition between the query and the event emitter
|
||||
return;
|
||||
}
|
||||
|
||||
yield tracked(msg.messageid, msg);
|
||||
|
||||
// update the cursor so that we don't send this post again
|
||||
lastMessageCreatedAt = msg.time;
|
||||
}
|
||||
|
||||
// yield the messages we fetched from the db
|
||||
for (const post of newMessagesSinceLastMessage) {
|
||||
yield* maybeYield(post);
|
||||
}
|
||||
|
||||
// yield any new messages from the event emitter
|
||||
for await (const [, post] of iterable) {
|
||||
yield* maybeYield(post);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") return;
|
||||
console.error("Error in onAdd subscription:", e);
|
||||
}
|
||||
}),
|
||||
|
||||
onMessageEvent: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
.output(
|
||||
zAsyncIterable({
|
||||
tracked: true,
|
||||
yield: z.custom<MessageUpdate>(),
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ input, signal }) {
|
||||
try {
|
||||
const iterable = ee.toIterable("messageUpdates", {
|
||||
signal,
|
||||
});
|
||||
|
||||
for await (const [chatId, data] of iterable) {
|
||||
if (chatId === input.chatId) {
|
||||
yield tracked(chatId, data);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") return;
|
||||
console.error("Error in onMessageEvent subscription:", e);
|
||||
}
|
||||
}),
|
||||
read: protectedProcedure
|
||||
.input(z.object({ chatId: zChatId }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await setReadMessage({
|
||||
chatId: input.chatId,
|
||||
db,
|
||||
reader: ctx.session.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
|
|
@ -17,22 +17,6 @@ export const getComuni = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
export const searchComuniByCatasto = async (catasto: string[]) => {
|
||||
try {
|
||||
return await db
|
||||
.selectFrom("comuni")
|
||||
.selectAll()
|
||||
.orderBy("nome", "asc")
|
||||
.where("catasto", "in", catasto)
|
||||
.execute();
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error while getting Comuni: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getProvincie = async () => {
|
||||
try {
|
||||
return await db.selectFrom("provincie").selectAll().execute();
|
||||
|
|
|
|||
|
|
@ -1,52 +1,58 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { subSeconds } from "date-fns";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
import type { Chats, ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { Etichette } from "~/schemas/public/Etichette";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { Users, UsersId } from "~/schemas/public/Users";
|
||||
import type { Message } from "~/server/api/routers/chat_sse";
|
||||
import { db } from "~/server/db";
|
||||
import { add, type ChatUserInfo, getRaw } from "~/server/services/chat.service";
|
||||
import type { WhoIsTyping } from "~/server/sse";
|
||||
import { getKeydbClient } from "~/utils/keydb";
|
||||
import { getUserInfo, type UserInfoQueryType } from "~/utils/kysely-helper";
|
||||
import { getUserInfo } from "~/utils/kysely-helper";
|
||||
|
||||
export type ActiveChatsType = {
|
||||
id: ChatsChatid;
|
||||
created_at: Date;
|
||||
userinfo: UserInfoQueryType;
|
||||
lastmessage: {
|
||||
message: string | null;
|
||||
time: Date;
|
||||
isLastSender: boolean;
|
||||
} | null;
|
||||
export type ActiveChatsType = Pick<Chats, "chatid" | "created_at"> &
|
||||
Pick<Users, "id" | "username" | "email" | "nome"> & {
|
||||
lastmessage: Message | null;
|
||||
etichette: Etichette[];
|
||||
};
|
||||
|
||||
export const getActiveInfos = async ({
|
||||
userId,
|
||||
}: {
|
||||
userId: UsersId;
|
||||
}): Promise<ActiveChatsType[]> => {
|
||||
export const getActiveInfos = async (): Promise<ActiveChatsType[]> => {
|
||||
const chats = await db
|
||||
.selectFrom("chats")
|
||||
.innerJoin("users", "users.id", "chats.user_ref")
|
||||
.select((eb) => [
|
||||
"chatid",
|
||||
"created_at",
|
||||
getUserInfo(),
|
||||
"chats.created_at",
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"nome",
|
||||
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("messages")
|
||||
.selectAll("messages")
|
||||
.whereRef("messages.chatid", "=", "chats.chatid")
|
||||
.leftJoin("users", "users.id", "messages.sender")
|
||||
.innerJoin("users", "users.id", "messages.sender")
|
||||
.select([
|
||||
"messages.message",
|
||||
"messages.messageid",
|
||||
"messages.chatid",
|
||||
(eb) =>
|
||||
eb
|
||||
.cast<string>(
|
||||
eb.fn("to_char", [
|
||||
"messages.time",
|
||||
eb.val("YYYY-MM-DD HH24:MI:SS"),
|
||||
]),
|
||||
"text",
|
||||
)
|
||||
.as("time"),
|
||||
"messages.sender",
|
||||
"messages.isread",
|
||||
"users.nome as sender_nome",
|
||||
"users.isAdmin as sender_isAdmin",
|
||||
])
|
||||
.orderBy("messages.time", "desc")
|
||||
.orderBy("messages.messageid", "desc")
|
||||
.limit(1),
|
||||
).as("lastMessage"),
|
||||
).as("lastmessage"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("etichette")
|
||||
|
|
@ -67,33 +73,7 @@ export const getActiveInfos = async ({
|
|||
.orderBy("created_at", "desc")
|
||||
.execute();
|
||||
|
||||
const clean = chats
|
||||
.map((chat) => {
|
||||
if (!chat.userinfo) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
created_at: chat.created_at,
|
||||
etichette: chat.etichette as Etichette[],
|
||||
id: chat.chatid,
|
||||
lastmessage: chat.lastMessage
|
||||
? {
|
||||
isLastSender: chat.lastMessage.sender === userId,
|
||||
message: chat.lastMessage.message,
|
||||
time: new Date(chat.lastMessage.time),
|
||||
}
|
||||
: null,
|
||||
userinfo: chat.userinfo,
|
||||
};
|
||||
})
|
||||
.filter((chat): chat is NonNullable<typeof chat> => chat !== null)
|
||||
.sort((a, b) => {
|
||||
const timeA = a.lastmessage ? new Date(a.lastmessage.time).getTime() : 0;
|
||||
const timeB = b.lastmessage ? new Date(b.lastmessage.time).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
return clean;
|
||||
return chats;
|
||||
};
|
||||
|
||||
export const getChatInfo = async ({
|
||||
|
|
@ -189,122 +169,3 @@ export const getChatByUserIdHandler = async ({
|
|||
.executeTakeFirst();
|
||||
return chat?.chatid || null;
|
||||
};
|
||||
|
||||
// Redis key prefix for typing status
|
||||
const TYPING_KEY_PREFIX = "chat:typing:";
|
||||
const TYPING_TTL_SECONDS = 3;
|
||||
|
||||
// const ONLINEUSER_KEY_PREFIX = "chat:online:";
|
||||
|
||||
export const addTyping = async ({
|
||||
chatId,
|
||||
userId,
|
||||
username,
|
||||
}: {
|
||||
chatId: ChatsChatid;
|
||||
userId: UsersId;
|
||||
username: string;
|
||||
}) => {
|
||||
try {
|
||||
const keydb = getKeydbClient();
|
||||
const typingKey = `${TYPING_KEY_PREFIX}${chatId}`;
|
||||
const typingData = JSON.stringify({
|
||||
userId,
|
||||
username,
|
||||
});
|
||||
if (!keydb) {
|
||||
console.warn("KeyDB client is not initialized. Skipping typing status.");
|
||||
return;
|
||||
}
|
||||
// Add the user to the Redis set with a TTL
|
||||
await keydb.zadd(typingKey, Date.now(), typingData);
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error adding typing status to Redis: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeTyping = async ({
|
||||
chatId,
|
||||
userId,
|
||||
username,
|
||||
}: {
|
||||
chatId: ChatsChatid;
|
||||
userId: UsersId;
|
||||
username: string;
|
||||
}) => {
|
||||
try {
|
||||
const keydb = getKeydbClient();
|
||||
const typingKey = `${TYPING_KEY_PREFIX}${chatId}`;
|
||||
if (!keydb) {
|
||||
console.warn("KeyDB client is not initialized. Skipping typing removal.");
|
||||
return;
|
||||
}
|
||||
|
||||
await keydb.zrem(typingKey, JSON.stringify({ userId, username }));
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error removing typing status from Redis: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeStaleTyping = async (chatId: ChatsChatid) => {
|
||||
try {
|
||||
const keydb = getKeydbClient();
|
||||
const typingKey = `${TYPING_KEY_PREFIX}${chatId}`;
|
||||
const now = new Date();
|
||||
const cutoff = subSeconds(now, TYPING_TTL_SECONDS);
|
||||
if (!keydb) {
|
||||
console.warn(
|
||||
"KeyDB client is not initialized. Skipping stale typing removal.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await keydb.zremrangebyscore(typingKey, 0, cutoff.getTime());
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Error removing stale typing status from Redis: " +
|
||||
(e as Error).message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentlyTyping = async (
|
||||
chatId: ChatsChatid,
|
||||
): Promise<WhoIsTyping[]> => {
|
||||
try {
|
||||
const keydb = getKeydbClient();
|
||||
const typingKey = `${TYPING_KEY_PREFIX}${chatId}`;
|
||||
if (!keydb) {
|
||||
console.warn("KeyDB client is not initialized. Skipping typing status.");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all members of the Redis set
|
||||
const allTypers = await keydb.zrange(typingKey, 0, -1);
|
||||
|
||||
if (!allTypers || allTypers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse the entries into the WhoIsTyping structure
|
||||
const typingStatus: WhoIsTyping[] = allTypers.map((entry) => {
|
||||
const { userId, username } = JSON.parse(entry) as WhoIsTyping;
|
||||
return { userId, username };
|
||||
});
|
||||
|
||||
return typingStatus;
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error getting typing status from Redis: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const InviteAcceptance = async (
|
|||
|
||||
export type TokenVerErrors = { _tag: "TokenNotFound" } | { _tag: "Expired" };
|
||||
|
||||
export async function verifyInviteToken(
|
||||
async function verifyInviteToken(
|
||||
token: string,
|
||||
): Promise<Result<UserInvites, TokenVerErrors>> {
|
||||
try {
|
||||
|
|
@ -164,7 +164,7 @@ export async function verifyInviteToken(
|
|||
}
|
||||
}
|
||||
|
||||
export async function consumeInviteToken(token: string) {
|
||||
async function consumeInviteToken(token: string) {
|
||||
try {
|
||||
await db.deleteFrom("user_invites").where("token", "=", token).execute();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { Messages, MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { Querier } from "~/server/db";
|
||||
|
||||
export type ChatMessage = Messages & {
|
||||
sender_isAdmin: boolean;
|
||||
sender_nome: string;
|
||||
};
|
||||
|
||||
export const editMessage = async ({
|
||||
db,
|
||||
messageId,
|
||||
|
|
@ -45,16 +40,16 @@ export const setReadMessage = async ({
|
|||
reader: UsersId;
|
||||
}) => {
|
||||
try {
|
||||
await db
|
||||
const result = await db
|
||||
.updateTable("messages")
|
||||
.set({
|
||||
isread: true,
|
||||
})
|
||||
.set({ isread: true })
|
||||
.where("messages.isread", "=", false)
|
||||
.where("messages.sender", "!=", reader)
|
||||
.where("messages.chatid", "=", chatId)
|
||||
.returning("messages.chatid")
|
||||
.returning("messages.messageid")
|
||||
.execute();
|
||||
|
||||
return result.length > 0;
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import type { Users, UsersId } from "~/schemas/public/Users";
|
|||
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
export type ServizioOrdiniTable = Omit<OrdiniTable, "rinnovo_id">;
|
||||
type ServizioOrdiniTable = Omit<OrdiniTable, "rinnovo_id">;
|
||||
//export type ServizioOrdini = Selectable<ServizioOrdiniTable>;
|
||||
export type NewServizioOrdini = Insertable<ServizioOrdiniTable>;
|
||||
type NewServizioOrdini = Insertable<ServizioOrdiniTable>;
|
||||
//export type ServizioOrdiniUpdate = Updateable<ServizioOrdiniTable>;
|
||||
export type RinnovoOrdiniTable = Omit<OrdiniTable, "servizio_id">;
|
||||
//export type RinnovoOrdini = Selectable<RinnovoOrdiniTable>;
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
import EventEmitter, { on } from "node:events";
|
||||
import type { ChatsChatid } from "~/schemas/public/Chats";
|
||||
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import type { ChatMessage } from "~/server/services/messages.service";
|
||||
|
||||
export type WhoIsTyping = {
|
||||
userId: UsersId;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type OnlineStatus = {
|
||||
userId: UsersId;
|
||||
nome: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
type ChatUpdate =
|
||||
| {
|
||||
eventType: "online";
|
||||
data: OnlineStatus[];
|
||||
}
|
||||
| {
|
||||
eventType: "typing";
|
||||
data: WhoIsTyping[];
|
||||
};
|
||||
|
||||
// export type AddEvent = {
|
||||
// eventType: "add";
|
||||
// data: ChatMessage;
|
||||
// };
|
||||
|
||||
type EditEvent = {
|
||||
eventType: "edit";
|
||||
data: { messageId: MessagesMessageid; message: string };
|
||||
};
|
||||
|
||||
type DeleteEvent = {
|
||||
eventType: "delete";
|
||||
data: MessagesMessageid;
|
||||
};
|
||||
|
||||
export type MessageUpdate = EditEvent | DeleteEvent;
|
||||
|
||||
interface Events {
|
||||
add: [chatId: ChatsChatid, data: ChatMessage];
|
||||
|
||||
test: [ch: string, data: string];
|
||||
notificaUpdate: [userId: UsersId];
|
||||
messageUpdates: [chatId: ChatsChatid, data: MessageUpdate];
|
||||
chatUpdates: [chatId: ChatsChatid, data: ChatUpdate];
|
||||
}
|
||||
|
||||
enum LogLevel {
|
||||
NONE = 0, // No logs
|
||||
ERROR = 1, // Only errors
|
||||
WARN = 2, // Warnings and errors
|
||||
INFO = 3, // Info, warnings, and errors
|
||||
DEBUG = 4, // Dbg, info, warnings, and errors
|
||||
}
|
||||
|
||||
class IterableEventEmitter extends EventEmitter<Events> {
|
||||
private logLevel: LogLevel = LogLevel.INFO; // Default log level
|
||||
|
||||
// Method to set the log level
|
||||
setLogLevel(level: LogLevel): void {
|
||||
this.logLevel = level;
|
||||
}
|
||||
|
||||
// Method to log messages based on the current log level
|
||||
private log(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
if (this.logLevel >= level) {
|
||||
console.log(message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to log emitted events
|
||||
private logEvent(eventName: string | symbol, args: unknown[]): void {
|
||||
this.log(
|
||||
LogLevel.DEBUG,
|
||||
`[EventEmitter] Event emitted: ${String(eventName)}`,
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
// Method to log added listeners
|
||||
private logListener(eventName: string | symbol): void {
|
||||
this.log(
|
||||
LogLevel.INFO,
|
||||
`[EventEmitter] Listener added for event: ${String(eventName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Method to log removed listeners
|
||||
private logRemoveListener(eventName: string | symbol): void {
|
||||
this.log(
|
||||
LogLevel.INFO,
|
||||
`[EventEmitter] Listener removed for event: ${String(eventName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Override the `emit` method to include logging
|
||||
emit<TEventName extends keyof Events>(
|
||||
eventName: TEventName,
|
||||
...args: Events[TEventName]
|
||||
): boolean {
|
||||
this.logEvent(eventName, args); // Log the event
|
||||
//@ts-expect-error
|
||||
return super.emit(eventName, ...args); // Call the original `emit` method
|
||||
}
|
||||
|
||||
// Override the `on` method to include logging
|
||||
on<TEventName extends keyof Events>(
|
||||
eventName: TEventName,
|
||||
listener: (...args: Events[TEventName]) => void,
|
||||
): this {
|
||||
this.logListener(eventName); // Log the listener addition
|
||||
//@ts-expect-error
|
||||
return super.on(eventName, listener); // Call the original `on` method
|
||||
}
|
||||
|
||||
// Override the `off` method to include logging
|
||||
off<TEventName extends keyof Events>(
|
||||
eventName: TEventName,
|
||||
listener: (...args: Events[TEventName]) => void,
|
||||
): this {
|
||||
this.logRemoveListener(eventName); // Log the listener removal
|
||||
//@ts-expect-error
|
||||
return super.off(eventName, listener); // Call the original `off` method
|
||||
}
|
||||
|
||||
toIterable<TEventName extends keyof Events>(
|
||||
eventName: TEventName,
|
||||
opts?: NonNullable<Parameters<typeof on>[2]>,
|
||||
): AsyncIterable<Events[TEventName]> {
|
||||
this.log(
|
||||
LogLevel.DEBUG,
|
||||
`[EventEmitter] Creating async iterable for event: ${String(eventName)}`,
|
||||
);
|
||||
return on(this, eventName, opts) as AsyncIterable<Events[TEventName]>;
|
||||
}
|
||||
}
|
||||
|
||||
// In a real app, you'd probably use Redis or something
|
||||
export const ee = new IterableEventEmitter();
|
||||
|
||||
// Example: Set log level to DEBUG
|
||||
ee.setLogLevel(LogLevel.ERROR);
|
||||
|
||||
ee.setMaxListeners(200);
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/** biome-ignore-all lint/suspicious/noExplicitAny: <intended> */
|
||||
import type { TrackedEnvelope } from "@trpc/server";
|
||||
import { isTrackedEnvelope, tracked } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
function isAsyncIterable<TValue, TReturn = unknown>(
|
||||
value: unknown,
|
||||
): value is AsyncIterable<TValue, TReturn> {
|
||||
return !!value && typeof value === "object" && Symbol.asyncIterator in value;
|
||||
}
|
||||
const trackedEnvelopeSchema =
|
||||
z.custom<TrackedEnvelope<unknown>>(isTrackedEnvelope);
|
||||
/**
|
||||
* A Zod schema helper designed specifically for validating async iterables. This schema ensures that:
|
||||
* 1. The value being validated is an async iterable.
|
||||
* 2. Each item yielded by the async iterable conforms to a specified type.
|
||||
* 3. The return value of the async iterable, if any, also conforms to a specified type.
|
||||
*/
|
||||
export function zAsyncIterable<
|
||||
TYield,
|
||||
TReturn = void,
|
||||
Tracked extends boolean = false,
|
||||
>(opts: {
|
||||
yield: z.ZodType<TYield, any, any>;
|
||||
return?: z.ZodType<TReturn, any, any>;
|
||||
tracked?: Tracked;
|
||||
}) {
|
||||
const baseSchema = z.custom<
|
||||
AsyncIterable<
|
||||
Tracked extends true
|
||||
? TrackedEnvelope<z.input<typeof opts.yield>>
|
||||
: z.input<typeof opts.yield>,
|
||||
z.input<typeof opts.return>
|
||||
>
|
||||
>((val: unknown): val is AsyncIterable<any, any> => isAsyncIterable(val), {
|
||||
message: "Expected AsyncIterable",
|
||||
});
|
||||
|
||||
return baseSchema.transform(async function* (iter) {
|
||||
const iterator = iter[Symbol.asyncIterator]();
|
||||
try {
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: <intended>
|
||||
let next;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <intended>
|
||||
while ((next = await iterator.next()) && !next.done) {
|
||||
if (opts.tracked) {
|
||||
const [id, data] = await trackedEnvelopeSchema.parseAsync(next.value);
|
||||
yield tracked(id, await opts.yield.parseAsync(data));
|
||||
continue;
|
||||
}
|
||||
yield await opts.yield.parseAsync(next.value);
|
||||
}
|
||||
if (opts.return) {
|
||||
return (await opts.return.parseAsync(next.value)) as TReturn;
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
await iterator.return?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -99,30 +99,37 @@ export const zEventId = z.custom<EventQueueEventId>(
|
|||
);
|
||||
|
||||
export const MessageDataSchema = z.object({
|
||||
messageid: z.custom<MessagesMessageid>((val) => typeof val === "string"),
|
||||
messageid: zMessageId,
|
||||
message: z.string().nullable(),
|
||||
chatid: zChatId,
|
||||
time: z.string(),
|
||||
sender_nome: z.string(),
|
||||
sender_isAdmin: z.boolean(),
|
||||
});
|
||||
export const MessageEventSchema = z.object({
|
||||
eventType: z.literal("message"),
|
||||
data: MessageDataSchema,
|
||||
sender: zUserId,
|
||||
isread: z.boolean(),
|
||||
});
|
||||
|
||||
const TypingUpdateEventSchema = z.object({
|
||||
eventType: z.literal("typing_update"),
|
||||
data: z.array(z.string()), // List of users currently typing
|
||||
});
|
||||
|
||||
export const ChatEventSchema = z.discriminatedUnion("eventType", [
|
||||
MessageEventSchema,
|
||||
TypingUpdateEventSchema,
|
||||
|
||||
]);
|
||||
export const TypingDataSchema = z.array(
|
||||
z.object({
|
||||
userId: zUserId,
|
||||
username: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const SidebarUpdateSchema = z.object({
|
||||
chatId: zChatId,
|
||||
lastMessage: z.string(),
|
||||
senderName: z.string(),
|
||||
});
|
||||
|
||||
export const MessageUpdateEventSchema = z.discriminatedUnion("eventType", [
|
||||
z.object({
|
||||
eventType: z.literal("edit"),
|
||||
data: z.object({ messageId: zMessageId, message: z.string() }),
|
||||
}),
|
||||
z.object({
|
||||
eventType: z.literal("delete"),
|
||||
data: zMessageId,
|
||||
}),
|
||||
z.object({ eventType: z.literal("read_receipt"), data: z.null() }), // all unread messages in this chat are now read
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
import { isValid } from "date-fns";
|
||||
import { expressionBuilder } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
import type Database from "~/schemas/Database";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
// export function getMessagesArray() {
|
||||
// const eb = expressionBuilder<Database, "chats">();
|
||||
// return jsonArrayFrom(
|
||||
// eb
|
||||
// .selectFrom("messages")
|
||||
// .selectAll("messages")
|
||||
// .whereRef("messages.chatid", "=", "chats.chatid")
|
||||
// .leftJoin("users", "users.id", "messages.sender")
|
||||
// .select(["users.nome as sender_nome", "users.isAdmin as sender_isAdmin"])
|
||||
// .orderBy("messages.time", "asc"),
|
||||
// ).as("messagesArray");
|
||||
// }
|
||||
export type UserInfoQueryType = {
|
||||
id: UsersId;
|
||||
username: string;
|
||||
email: string;
|
||||
nome: string | null;
|
||||
};
|
||||
|
||||
export function getUserInfo() {
|
||||
const eb = expressionBuilder<Database, "chats">();
|
||||
|
|
@ -54,10 +34,10 @@ export function withVideos() {
|
|||
).as("videos");
|
||||
}
|
||||
|
||||
export function parseNullableDateString(str: string | null): Date | null {
|
||||
if (str === null) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(str);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
// export function parseNullableDateString(str: string | null): Date | null {
|
||||
// if (str === null) {
|
||||
// return null;
|
||||
// }
|
||||
// const date = new Date(str);
|
||||
// return isValid(date) ? date : null;
|
||||
// }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue