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:
Marco Pedone 2026-04-22 19:17:54 +02:00
parent f5d1530683
commit 1971251490
33 changed files with 2452 additions and 1873 deletions

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

View 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");
};

View 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>
);
}

View 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 };

View 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";

View 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>
</>
);
};

View 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>
);

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

View 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>
);
}

View file

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

View file

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

View file

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

View file

@ -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", {

View file

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

View file

@ -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({

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }))

View file

@ -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;
}),
});

View file

@ -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();
}
}),
});

View file

@ -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;
}),
});

View file

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

View file

@ -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}`,
});
}
};

View file

@ -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) {

View file

@ -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",

View file

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

View file

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

View file

@ -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?.();
}
});
}

View file

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

View file

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