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,
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,12 @@ 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 { 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 +62,7 @@ export default async function RootLayout({
const user = await loadUser();
const userId = await loadUserId();
const chats = await loadChatsList();
return (
<html lang={locale}>
@ -70,9 +73,11 @@ export default async function RootLayout({
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<ChatsInitializationProvider>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
<ChatsProvider initialChats={chats}>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsProvider>
</ChatsInitializationProvider>
</RetainingStoreProvider>
</SocketProvider>

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,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 { IGetChatsListResponse } from "@/entities/chats/types";
import { AudioUrls } from "@/shared/constants/audio";
import { useAudio } from "../audio/useAudio";
import { useSocketEvent } from "../socket/useSocketEvent";
interface UseChatsSocketOptions {
export interface UseChatsSocketOptions {
initialChats?: IGetChatsListResponse;
enableNotificationSound?: boolean;
}
export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
const { enableNotificationSound = true } = options;
const initialChats = options.initialChats ?? {
categorizedChats: {},
startedChats: [],
@ -18,14 +23,28 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
totalUnreadCount: 0,
};
const { playNewMessageNotification } = useAudio({
enabled: enableNotificationSound,
preload: [AudioUrls.NewMessage],
});
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 +54,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,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];