notification-sound

not working in safari
This commit is contained in:
gofnnp 2025-07-31 13:57:17 +04:00
parent d135674e46
commit d1fe43463e
19 changed files with 201 additions and 96 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { audioService } from "@/services/audio";
import { AudioUrls } from "@/shared/constants/audio";
interface UseAudioOptions {
enabled?: boolean;
preload?: AudioUrls[];
}
export const useAudio = (options: UseAudioOptions = {}) => {
const { enabled = true, preload = [] } = options;
useEffect(() => {
audioService.setEnabled(enabled);
}, [enabled]);
useEffect(() => {
if (preload.length > 0) {
audioService.preload(preload);
}
}, [preload]);
const playNewMessageNotification = useCallback(() => {
audioService.play(AudioUrls.NewMessage);
}, []);
return useMemo(
() => ({
playNewMessageNotification,
isEnabled: audioService.isAudioEnabled(),
}),
[playNewMessageNotification]
);
};

View File

@ -3,14 +3,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { IGetChatsListResponse } from "@/entities/chats/types"; import { IGetChatsListResponse } from "@/entities/chats/types";
import { AudioUrls } from "@/shared/constants/audio";
import { useAudio } from "../audio/useAudio";
import { useSocketEvent } from "../socket/useSocketEvent"; import { useSocketEvent } from "../socket/useSocketEvent";
interface UseChatsSocketOptions { export interface UseChatsSocketOptions {
initialChats?: IGetChatsListResponse; initialChats?: IGetChatsListResponse;
enableNotificationSound?: boolean;
} }
export const useChatsSocket = (options: UseChatsSocketOptions = {}) => { export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
const { enableNotificationSound = true } = options;
const initialChats = options.initialChats ?? { const initialChats = options.initialChats ?? {
categorizedChats: {}, categorizedChats: {},
startedChats: [], startedChats: [],
@ -18,14 +23,28 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
totalUnreadCount: 0, totalUnreadCount: 0,
}; };
const { playNewMessageNotification } = useAudio({
enabled: enableNotificationSound,
preload: [AudioUrls.NewMessage],
});
const [isInChat, setIsInChat] = useState(false);
const [chats, setChats] = useState<IGetChatsListResponse>(initialChats); const [chats, setChats] = useState<IGetChatsListResponse>(initialChats);
const [unreadCount, setUnreadCount] = useState<number>( const [unreadCount, setUnreadCount] = useState<number>(
initialChats.totalUnreadCount initialChats.totalUnreadCount
); );
useSocketEvent("chat_joined", () => setIsInChat(true));
useSocketEvent("chat_left", () => setIsInChat(false));
useSocketEvent("chats_updated", chats => setChats(chats)); useSocketEvent("chats_updated", chats => setChats(chats));
useSocketEvent("unread_messages_count", count => useSocketEvent("unread_messages_count", count =>
setUnreadCount(count.unreadCount) setUnreadCount(prev => {
if (!isInChat && prev < count.unreadCount) {
playNewMessageNotification();
}
return count.unreadCount;
})
); );
return useMemo( return useMemo(
@ -35,7 +54,8 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
startedChats: chats.startedChats, startedChats: chats.startedChats,
categorizedChats: chats.categorizedChats, categorizedChats: chats.categorizedChats,
totalUnreadCount: unreadCount, totalUnreadCount: unreadCount,
isInChat,
}), }),
[chats, unreadCount] [chats, unreadCount, isInChat]
); );
}; };

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

View File

@ -0,0 +1,58 @@
"use client";
import { AudioUrls, getAudioUrl } from "@/shared/constants/audio";
class AudioService {
private audioElements = new Map<string, HTMLAudioElement>();
private isEnabled = true;
private getAudioElement(url: string): HTMLAudioElement | undefined {
if (!this.audioElements.has(url)) {
const audio = new Audio(url);
audio.preload = "auto";
this.audioElements.set(url, audio);
}
return this.audioElements.get(url);
}
play(key: AudioUrls): Promise<void> {
if (!this.isEnabled) return Promise.resolve();
try {
const audio = this.getAudioElement(getAudioUrl(key));
if (audio) {
audio.currentTime = 0;
return audio.play();
}
return Promise.resolve();
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to play audio:", error);
return Promise.resolve();
}
}
setEnabled(enabled: boolean): void {
this.isEnabled = enabled;
}
isAudioEnabled(): boolean {
return this.isEnabled;
}
preload(keys: AudioUrls[]): void {
keys.forEach(key => {
this.getAudioElement(getAudioUrl(key));
});
}
dispose(): void {
this.audioElements.forEach(audio => {
audio.pause();
audio.src = "";
});
this.audioElements.clear();
}
}
export const audioService = new AudioService();

View File

@ -0,0 +1,9 @@
const audioUrls: Record<AudioUrls, string> = {
"new-message": "/audio/notification-new-message-1.mp3",
};
export enum AudioUrls {
NewMessage = "new-message",
}
export const getAudioUrl = (key: AudioUrls) => audioUrls[key];