Merge pull request #33 from pennyteenycat/notification-sound

Notification sound
This commit is contained in:
pennyteenycat 2025-08-07 15:18:52 +03:00 committed by GitHub
commit 630ebaaf1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 182 additions and 98 deletions

7
global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export {};

Binary file not shown.

Binary file not shown.

View File

@ -4,14 +4,13 @@ import {
ChatModalsWrapper,
MessageInputWrapper,
} from "@/components/domains/chat";
import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./page.module.scss";
export default function Chat() {
return (
<div className={styles.container}>
<ChatHeader chatsPromise={loadChatsList()} />
<ChatHeader />
<ChatMessagesWrapper />
<MessageInputWrapper />
<ChatModalsWrapper />

View File

@ -10,7 +10,6 @@ import {
NewMessagesWrapperSkeleton,
} from "@/components/domains/chat";
import { NavigationBar } from "@/components/layout";
import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./page.module.scss";
@ -19,23 +18,21 @@ export const revalidate = 0;
export const fetchCache = "force-no-store";
export default function Chats() {
const chatsPromise = loadChatsList();
return (
<div className={styles.container}>
<ChatListHeader />
<section className={styles.categories}>
<Suspense fallback={<NewMessagesWrapperSkeleton />}>
<NewMessagesWrapper chatsPromise={chatsPromise} />
<NewMessagesWrapper />
</Suspense>
<Suspense fallback={<CorrespondenceStartedSkeleton />}>
<CorrespondenceStartedWrapper chatsPromise={chatsPromise} />
<CorrespondenceStartedWrapper />
</Suspense>
<Suspense fallback={<ChatCategoriesSkeleton />}>
<ChatCategories chatsPromise={chatsPromise} />
<ChatCategories />
</Suspense>
</section>
<NavigationBar chatsPromise={chatsPromise} />
<NavigationBar />
</div>
);
}

View File

@ -1,5 +1,4 @@
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
import { loadChatsList } from "@/entities/chats/loaders";
import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss";
@ -9,13 +8,12 @@ export default function CoreLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const chatsPromise = loadChatsList();
return (
<DrawerProvider>
<ChatStoreProvider>
<Header className={styles.navBar} chatsPromise={chatsPromise} />
<Header className={styles.navBar} />
<main className={styles.main}>{children}</main>
<NavigationBar chatsPromise={chatsPromise} />
<NavigationBar />
</ChatStoreProvider>
</DrawerProvider>
);

View File

@ -28,7 +28,7 @@ export default function Home() {
return (
<section className={styles.page}>
<Suspense fallback={<NewMessagesSectionSkeleton />}>
<NewMessagesSection chatsPromise={chatsPromise} />
<NewMessagesSection />
</Suspense>
<Horoscope />

View File

@ -1,5 +1,4 @@
import { DrawerProvider, Header } from "@/components/layout";
import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./layout.module.scss";
@ -10,7 +9,7 @@ export default function CoreLayout({
}>) {
return (
<DrawerProvider>
<Header className={styles.navBar} chatsPromise={loadChatsList()} />
<Header className={styles.navBar} />
<main className={styles.main}>{children}</main>
</DrawerProvider>
);

View File

@ -10,10 +10,13 @@ import { getMessages } from "next-intl/server";
import clsx from "clsx";
import YandexMetrika from "@/components/analytics/YandexMetrika";
import { loadChatsList } from "@/entities/chats/loaders";
import { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing";
import { AppUiStoreProvider } from "@/providers/app-ui-store-provider";
import { AudioProvider } from "@/providers/audio-provider";
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
import { ChatsProvider } from "@/providers/chats-provider";
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
import SocketProvider from "@/providers/socket-provider";
import { ToastProvider } from "@/providers/toast-provider";
@ -60,6 +63,7 @@ export default async function RootLayout({
const user = await loadUser();
const userId = await loadUserId();
const chats = await loadChatsList();
return (
<html lang={locale}>
@ -69,11 +73,15 @@ export default async function RootLayout({
<UserProvider user={user}>
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<AudioProvider>
<ChatsInitializationProvider>
<ChatsProvider initialChats={chats}>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsProvider>
</ChatsInitializationProvider>
</AudioProvider>
</RetainingStoreProvider>
</SocketProvider>
</UserProvider>

View File

@ -1,23 +1,17 @@
"use client";
import { use, useState } from "react";
import { useState } from "react";
import { Skeleton } from "@/components/ui";
import { Chips } from "@/components/widgets";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChats } from "@/providers/chats-provider";
import { CategoryChats, ChatItemsList } from "..";
const MAX_HIDE_VISIBLE_COUNT = 3;
interface ChatCategoriesProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
const chats = use(chatsPromise);
const { categorizedChats } = useChatsSocket({ initialChats: chats });
export default function ChatCategories() {
const { categorizedChats } = useChats();
const [activeChip, setActiveChip] = useState<string>("All");
const [maxVisibleChats, setMaxVisibleChats] = useState<

View File

@ -1,6 +1,6 @@
"use client";
import { use, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@ -13,26 +13,20 @@ import {
UserAvatar,
} from "@/components/ui";
import { revalidateChatsPage } from "@/entities/chats/actions";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChat } from "@/providers/chat-provider";
import { useChatStore } from "@/providers/chat-store-provider";
import { useChats } from "@/providers/chats-provider";
import { formatSecondsToHHMMSS } from "@/shared/utils/date";
import { delay } from "@/shared/utils/delay";
import styles from "./ChatHeader.module.scss";
interface ChatHeaderProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function ChatHeader({ chatsPromise }: ChatHeaderProps) {
export default function ChatHeader() {
const t = useTranslations("Chat");
const router = useRouter();
const currentChat = useChatStore(state => state.currentChat);
const { isLoadingAdvisorMessage, isAvailableChatting } = useChat();
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const { totalUnreadCount } = useChats();
const [timer, setTimer] = useState(0);
useEffect(() => {

View File

@ -1,25 +1,16 @@
"use client";
import { use } from "react";
import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider";
import { ChatItemsList, CorrespondenceStarted } from "..";
interface CorrespondenceStartedWrapperProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function CorrespondenceStartedWrapper({
chatsPromise,
}: CorrespondenceStartedWrapperProps) {
export default function CorrespondenceStartedWrapper() {
const t = useTranslations("Chat");
const chats = use(chatsPromise);
const { startedChats } = useChatsSocket({ initialChats: chats });
const { startedChats } = useChats();
const { isVisibleAll } = useAppUiStore(
state => state.chats.correspondenceStarted

View File

@ -1,25 +1,16 @@
"use client";
import { use } from "react";
import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider";
import { ChatItemsList, NewMessages } from "..";
interface NewMessagesWrapperProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NewMessagesWrapper({
chatsPromise,
}: NewMessagesWrapperProps) {
export default function NewMessagesWrapper() {
const t = useTranslations("Chat");
const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
const { unreadChats } = useChats();
const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);

View File

@ -1,24 +1,14 @@
"use client";
import { use } from "react";
import { NewMessages, ViewAll } from "@/components/domains/chat";
import { Skeleton } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider";
import styles from "./NewMessagesSection.module.scss";
interface NewMessagesSectionProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NewMessagesSection({
chatsPromise,
}: NewMessagesSectionProps) {
const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
export default function NewMessagesSection() {
const { unreadChats } = useChats();
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);

View File

@ -1,12 +1,10 @@
"use client";
import { use } from "react";
import Link from "next/link";
import clsx from "clsx";
import { Badge, Button, Icon, IconName, Typography } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { useDrawer } from "..";
@ -16,13 +14,11 @@ import styles from "./Header.module.scss";
interface HeaderProps {
className?: string;
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function Header({ className, chatsPromise }: HeaderProps) {
export default function Header({ className }: HeaderProps) {
const { open } = useDrawer();
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const { totalUnreadCount } = useChats();
return (
<header className={clsx(styles.header, className)}>

View File

@ -1,14 +1,12 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale } from "next-intl";
import clsx from "clsx";
import { Badge, Icon, Typography } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { NavItem, navItems } from "@/shared/constants/navigation";
import { stripLocale } from "@/shared/utils/path";
@ -20,16 +18,11 @@ const getBadge = (item: NavItem, totalUnreadCount: number) => {
return null;
};
interface NavigationBarProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NavigationBar({ chatsPromise }: NavigationBarProps) {
export default function NavigationBar() {
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const { totalUnreadCount } = useChats();
return (
<nav className={styles.container}>

View File

@ -0,0 +1,50 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
export const useAudio = () => {
const audio = useMemo(() => {
if (typeof window !== "undefined") {
const audioElement = new Audio("/audio/notification-new-message-1.wav");
audioElement.preload = "auto";
return audioElement;
}
return null;
}, []);
const _audioContext = useMemo(() => {
if (typeof window !== "undefined") {
return new (window.AudioContext || window.webkitAudioContext)();
}
return null;
}, []);
useEffect(() => {
if (!audio) return;
const handleClick = () => {
audio.currentTime = 0;
audio.play();
setTimeout(() => {
audio.pause();
}, 10);
};
document.addEventListener("click", handleClick, { once: true });
return () => {
document.removeEventListener("click", handleClick);
};
}, [audio]);
const playNewMessageNotification = useCallback(() => {
if (!audio) return;
audio.currentTime = 0;
audio.play();
}, [audio]);
return useMemo(
() => ({
playNewMessageNotification,
}),
[playNewMessageNotification]
);
};

View File

@ -3,11 +3,13 @@
import { useMemo, useState } from "react";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useAudioContext } from "@/providers/audio-provider";
import { useSocketEvent } from "../socket/useSocketEvent";
interface UseChatsSocketOptions {
export interface UseChatsSocketOptions {
initialChats?: IGetChatsListResponse;
enableNotificationSound?: boolean;
}
export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
@ -18,14 +20,25 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
totalUnreadCount: 0,
};
const { playNewMessageNotification } = useAudioContext();
const [isInChat, setIsInChat] = useState(false);
const [chats, setChats] = useState<IGetChatsListResponse>(initialChats);
const [unreadCount, setUnreadCount] = useState<number>(
initialChats.totalUnreadCount
);
useSocketEvent("chat_joined", () => setIsInChat(true));
useSocketEvent("chat_left", () => setIsInChat(false));
useSocketEvent("chats_updated", chats => setChats(chats));
useSocketEvent("unread_messages_count", count =>
setUnreadCount(count.unreadCount)
setUnreadCount(prev => {
if (!isInChat && prev < count.unreadCount) {
playNewMessageNotification();
}
return count.unreadCount;
})
);
return useMemo(
@ -35,7 +48,8 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
startedChats: chats.startedChats,
categorizedChats: chats.categorizedChats,
totalUnreadCount: unreadCount,
isInChat,
}),
[chats, unreadCount]
[chats, unreadCount, isInChat]
);
};

View File

@ -0,0 +1,29 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import { useAudio } from "@/hooks/audio/useAudio";
type AudioContextValue = ReturnType<typeof useAudio>;
const AudioContext = createContext<AudioContextValue | null>(null);
export function useAudioContext() {
const ctx = useContext(AudioContext);
if (!ctx) {
throw new Error("useAudio must be used within <AudioProvider>");
}
return ctx;
}
interface AudioProviderProps {
children: ReactNode;
}
export function AudioProvider({ children }: AudioProviderProps) {
const value = useAudio();
return (
<AudioContext.Provider value={value}>{children}</AudioContext.Provider>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import {
useChatsSocket,
UseChatsSocketOptions,
} from "@/hooks/chats/useChatsSocket";
type ChatsContextValue = ReturnType<typeof useChatsSocket>;
const ChatsContext = createContext<ChatsContextValue | null>(null);
export function useChats() {
const ctx = useContext(ChatsContext);
if (!ctx) {
throw new Error("useChats must be used within <ChatsProvider>");
}
return ctx;
}
interface ChatsProviderProps extends UseChatsSocketOptions {
children: ReactNode;
}
export function ChatsProvider({ children, initialChats }: ChatsProviderProps) {
const value = useChatsSocket({
initialChats,
});
return (
<ChatsContext.Provider value={value}>{children}</ChatsContext.Provider>
);
}