diff --git a/packages/react/src/hooks/use-echo.ts b/packages/react/src/hooks/use-echo.ts index 05ce47a0..800e13e9 100644 --- a/packages/react/src/hooks/use-echo.ts +++ b/packages/react/src/hooks/use-echo.ts @@ -2,6 +2,7 @@ import { type BroadcastDriver } from "laravel-echo"; import { useCallback, useEffect, useRef } from "react"; import { echo } from "../config"; import type { + BroadcastNotification, Channel, ChannelData, ChannelReturnType, @@ -163,6 +164,91 @@ export const useEcho = < }; }; +export const useEchoNotification = < + TPayload, + TDriver extends BroadcastDriver = BroadcastDriver, +>( + channelName: string, + callback: (payload: BroadcastNotification) => void = () => {}, + event: string | string[] = [], + dependencies: any[] = [], +) => { + const result = useEcho, TDriver, "private">( + channelName, + [], + callback, + dependencies, + "private", + ); + + const events = useRef( + toArray(event) + .map((e) => { + if (e.includes(".")) { + return [e, e.replace(/\./g, "\\")]; + } + + return [e, e.replace(/\\/g, ".")]; + }) + .flat(), + ); + const listening = useRef(false); + const initialized = useRef(false); + + const cb = useCallback( + (notification: BroadcastNotification) => { + if (!listening.current) { + return; + } + + if ( + events.current.length === 0 || + events.current.includes(notification.type) + ) { + callback(notification); + } + }, + dependencies.concat(events.current).concat([callback]), + ); + + const listen = useCallback(() => { + if (listening.current) { + return; + } + + if (!initialized.current) { + result.channel().notification(cb); + } + + listening.current = true; + initialized.current = true; + }, [cb]); + + const stopListening = useCallback(() => { + if (!listening.current) { + return; + } + + listening.current = false; + }, [cb]); + + useEffect(() => { + listen(); + }, dependencies.concat(events.current)); + + return { + ...result, + /** + * Stop listening for notification events + */ + stopListening, + /** + * Listen for notification events + */ + listen, + }; +}; + export const useEchoPresence = < TPayload, TDriver extends BroadcastDriver = BroadcastDriver, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c56e1ccd..8d900c11 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,6 +2,7 @@ export { configureEcho, echo } from "./config/index"; export { useEcho, useEchoModel, + useEchoNotification, useEchoPresence, useEchoPublic, } from "./hooks/use-echo"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 320a7121..648ec76a 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -16,6 +16,11 @@ export type Channel = { visibility: "private" | "public" | "presence"; }; +export type BroadcastNotification = TPayload & { + id: string; + type: string; +}; + export type ChannelReturnType< T extends BroadcastDriver, V extends Channel["visibility"], diff --git a/packages/react/tests/use-echo.test.ts b/packages/react/tests/use-echo.test.ts index 96f52de2..698c85f4 100644 --- a/packages/react/tests/use-echo.test.ts +++ b/packages/react/tests/use-echo.test.ts @@ -10,6 +10,7 @@ vi.mock("laravel-echo", () => { leaveChannel: vi.fn(), listen: vi.fn(), stopListening: vi.fn(), + notification: vi.fn(), }; const mockPublicChannel = { @@ -854,3 +855,290 @@ describe("useEchoPresence hook", async () => { expect(result.current.channel).not.toBeNull(); }); }); + +describe("useEchoNotification hook", async () => { + let echoModule: typeof import("../src/hooks/use-echo"); + let configModule: typeof import("../src/config/index"); + let echoInstance: Echo<"null">; + + beforeEach(async () => { + vi.resetModules(); + + echoInstance = new Echo({ + broadcaster: "null", + }); + + echoModule = await getEchoModule(); + configModule = await getConfigModule(); + + configModule.configureEcho({ + broadcaster: "null", + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to a private channel and listens for notifications", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + expect(result.current).toHaveProperty("leaveChannel"); + expect(typeof result.current.leave).toBe("function"); + expect(result.current).toHaveProperty("leave"); + expect(typeof result.current.leaveChannel).toBe("function"); + expect(result.current).toHaveProperty("listen"); + expect(typeof result.current.listen).toBe("function"); + expect(result.current).toHaveProperty("stopListening"); + expect(typeof result.current.stopListening).toBe("function"); + }); + + it("sets up a notification listener on a channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + expect(echoInstance.private).toHaveBeenCalledWith(channelName); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + }); + + it("handles notification filtering by event type", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const eventType = "specific-type"; + + renderHook(() => + echoModule.useEchoNotification( + channelName, + mockCallback, + eventType, + ), + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const matchingNotification = { + type: eventType, + data: { message: "test" }, + }; + const nonMatchingNotification = { + type: "other-type", + data: { message: "test" }, + }; + + notificationCallback(matchingNotification); + notificationCallback(nonMatchingNotification); + + expect(mockCallback).toHaveBeenCalledWith(matchingNotification); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); + }); + + it("handles multiple notification event types", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const events = ["type1", "type2"]; + + renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback, events), + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { type: events[0], data: {} }; + const notification2 = { type: events[1], data: {} }; + const notification3 = { type: "type3", data: {} }; + + notificationCallback(notification1); + notificationCallback(notification2); + notificationCallback(notification3); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).not.toHaveBeenCalledWith(notification3); + }); + + it("handles dotted and slashed notification event types", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const events = [ + "App.Notifications.First", + "App\\Notifications\\Second", + ]; + + renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback, events), + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { type: "App\\Notifications\\First", data: {} }; + const notification2 = { type: "App\\Notifications\\Second", data: {} }; + + notificationCallback(notification1); + notificationCallback(notification2); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it("accepts all notifications when no event types specified", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { type: "type1", data: {} }; + const notification2 = { type: "type2", data: {} }; + + notificationCallback(notification1); + notificationCallback(notification2); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it("cleans up subscriptions on unmount", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { unmount } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + expect(echoInstance.private).toHaveBeenCalledWith(channelName); + + expect(() => unmount()).not.toThrow(); + + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("won't subscribe multiple times to the same channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { unmount: unmount1 } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + const { unmount: unmount2 } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + expect(echoInstance.private).toHaveBeenCalledTimes(1); + + expect(() => unmount1()).not.toThrow(); + expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); + + expect(() => unmount2()).not.toThrow(); + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("can leave a channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + result.current.leaveChannel(); + + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("can leave all channel variations", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + result.current.leave(); + + expect(echoInstance.leave).toHaveBeenCalledWith(channelName); + }); + + it("can manually start and stop listening", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalledTimes(1); + + result.current.stopListening(); + result.current.listen(); + + expect(channel.notification).toHaveBeenCalledTimes(1); + }); + + it("stopListening prevents new notification listeners", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName, mockCallback), + ); + + result.current.stopListening(); + + expect(result.current.stopListening).toBeDefined(); + expect(typeof result.current.stopListening).toBe("function"); + }); + + it("callback and events are optional", async () => { + const channelName = "test-channel"; + + const { result } = renderHook(() => + echoModule.useEchoNotification(channelName), + ); + + expect(result.current).toHaveProperty("channel"); + expect(result.current.channel).not.toBeNull(); + }); +}); diff --git a/packages/vue/src/composables/useEcho.ts b/packages/vue/src/composables/useEcho.ts index db2c9035..b906c7f7 100644 --- a/packages/vue/src/composables/useEcho.ts +++ b/packages/vue/src/composables/useEcho.ts @@ -2,6 +2,7 @@ import { type BroadcastDriver } from "laravel-echo"; import { onMounted, onUnmounted, ref, watch } from "vue"; import { echo } from "../config"; import type { + BroadcastNotification, Channel, ChannelData, ChannelReturnType, @@ -178,6 +179,84 @@ export const useEcho = < }; }; +export const useEchoNotification = < + TPayload, + TDriver extends BroadcastDriver = BroadcastDriver, +>( + channelName: string, + callback: (payload: BroadcastNotification) => void = () => {}, + event: string | string[] = [], + dependencies: any[] = [], +) => { + const result = useEcho, TDriver, "private">( + channelName, + [], + callback, + dependencies, + "private", + ); + + const events = toArray(event) + .map((e) => { + if (e.includes(".")) { + return [e, e.replace(/\./g, "\\")]; + } + + return [e, e.replace(/\\/g, ".")]; + }) + .flat(); + + const listening = ref(false); + const initialized = ref(false); + + const cb = (notification: BroadcastNotification) => { + if (!listening.value) { + return; + } + + if (events.length === 0 || events.includes(notification.type)) { + callback(notification); + } + }; + + const listen = () => { + if (listening.value) { + return; + } + + if (!initialized.value) { + result.channel().notification(cb); + } + + listening.value = true; + initialized.value = true; + }; + + const stopListening = () => { + if (!listening.value) { + return; + } + + listening.value = false; + }; + + onMounted(() => { + listen(); + }); + + return { + ...result, + /** + * Stop listening for notification events + */ + stopListening, + /** + * Listen for notification events + */ + listen, + }; +}; + export const useEchoPresence = < TPayload, TDriver extends BroadcastDriver = BroadcastDriver, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 8010ab3e..ec443c79 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,6 +1,7 @@ export { useEcho, useEchoModel, + useEchoNotification, useEchoPresence, useEchoPublic, } from "./composables/useEcho"; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 320a7121..648ec76a 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -16,6 +16,11 @@ export type Channel = { visibility: "private" | "public" | "presence"; }; +export type BroadcastNotification = TPayload & { + id: string; + type: string; +}; + export type ChannelReturnType< T extends BroadcastDriver, V extends Channel["visibility"], diff --git a/packages/vue/tests/useEcho.test.ts b/packages/vue/tests/useEcho.test.ts index 2c80bcb4..98998fc6 100644 --- a/packages/vue/tests/useEcho.test.ts +++ b/packages/vue/tests/useEcho.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; import { useEcho, + useEchoNotification, useEchoPresence, useEchoPublic, } from "../src/composables/useEcho"; @@ -100,11 +101,39 @@ const getPresenceTestComponent = ( return mount(TestComponent); }; +const getNotificationTestComponent = ( + channelName: string, + callback: ((data: any) => void) | undefined, + event: string | string[] | undefined, + dependencies: any[] = [], +) => { + const TestComponent = defineComponent({ + setup() { + configureEcho({ + broadcaster: "null", + }); + + return { + ...useEchoNotification( + channelName, + callback, + event, + dependencies, + ), + }; + }, + template: "
", + }); + + return mount(TestComponent); +}; + vi.mock("laravel-echo", () => { const mockPrivateChannel = { leaveChannel: vi.fn(), listen: vi.fn(), stopListening: vi.fn(), + notification: vi.fn(), }; const mockPublicChannel = { @@ -698,3 +727,314 @@ describe("useEchoPresence hook", async () => { expect(wrapper.vm.channel).not.toBeNull(); }); }); + +describe("useEchoNotification hook", async () => { + let echoInstance: Echo<"null">; + let wrapper: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + + echoInstance = new Echo({ + broadcaster: "null", + }); + }); + + afterEach(() => { + wrapper.unmount(); + vi.clearAllMocks(); + }); + + it("subscribes to a private channel and listens for notifications", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + expect(wrapper.vm).toHaveProperty("leaveChannel"); + expect(typeof wrapper.vm.leaveChannel).toBe("function"); + + expect(wrapper.vm).toHaveProperty("leave"); + expect(typeof wrapper.vm.leave).toBe("function"); + + expect(wrapper.vm).toHaveProperty("listen"); + expect(typeof wrapper.vm.listen).toBe("function"); + + expect(wrapper.vm).toHaveProperty("stopListening"); + expect(typeof wrapper.vm.stopListening).toBe("function"); + }); + + it("sets up a notification listener on a channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + expect(echoInstance.private).toHaveBeenCalledWith(channelName); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + }); + + it("handles notification filtering by event type", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const eventType = "specific-type"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + eventType, + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const matchingNotification = { + type: eventType, + data: { message: "test" }, + }; + const nonMatchingNotification = { + type: "other-type", + data: { message: "test" }, + }; + + notificationCallback(matchingNotification); + notificationCallback(nonMatchingNotification); + + expect(mockCallback).toHaveBeenCalledWith(matchingNotification); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); + }); + + it("handles multiple notification event types", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const events = ["type1", "type2"]; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + events, + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { type: events[0], data: {} }; + const notification2 = { type: events[1], data: {} }; + const notification3 = { type: "type3", data: {} }; + + notificationCallback(notification1); + notificationCallback(notification2); + notificationCallback(notification3); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).not.toHaveBeenCalledWith(notification3); + }); + + it("handles dotted and slashed notification event types", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + const events = [ + "App.Notifications.First", + "App\\Notifications\\Second", + ]; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + events, + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { + type: "App\\Notifications\\First", + data: {}, + }; + const notification2 = { + type: "App\\Notifications\\Second", + data: {}, + }; + + notificationCallback(notification1); + notificationCallback(notification2); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it("accepts all notifications when no event types specified", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalled(); + + const notificationCallback = vi.mocked(channel.notification).mock + .calls[0][0]; + + const notification1 = { type: "type1", data: {} }; + const notification2 = { type: "type2", data: {} }; + + notificationCallback(notification1); + notificationCallback(notification2); + + expect(mockCallback).toHaveBeenCalledWith(notification1); + expect(mockCallback).toHaveBeenCalledWith(notification2); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it("cleans up subscriptions on unmount", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + expect(echoInstance.private).toHaveBeenCalledWith(channelName); + + wrapper.unmount(); + + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("won't subscribe multiple times to the same channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + const wrapper2 = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + expect(echoInstance.private).toHaveBeenCalledTimes(1); + + wrapper.unmount(); + expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); + + wrapper2.unmount(); + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("can leave a channel", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + wrapper.vm.leaveChannel(); + + expect(echoInstance.leaveChannel).toHaveBeenCalledWith( + `private-${channelName}`, + ); + }); + + it("can leave all channel variations", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + wrapper.vm.leave(); + + expect(echoInstance.leave).toHaveBeenCalledWith(channelName); + }); + + it("can manually start and stop listening", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + const channel = echoInstance.private(channelName); + expect(channel.notification).toHaveBeenCalledTimes(1); + + wrapper.vm.stopListening(); + wrapper.vm.listen(); + + expect(channel.notification).toHaveBeenCalledTimes(1); + }); + + it("stopListening prevents new notification listeners", async () => { + const mockCallback = vi.fn(); + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + mockCallback, + undefined, + ); + + wrapper.vm.stopListening(); + + expect(wrapper.vm.stopListening).toBeDefined(); + expect(typeof wrapper.vm.stopListening).toBe("function"); + }); + + it("callback and events are optional", async () => { + const channelName = "test-channel"; + + wrapper = getNotificationTestComponent( + channelName, + undefined, + undefined, + ); + + expect(wrapper.vm.channel).not.toBeNull(); + }); +});