diff --git a/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts b/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts index ad490174040..8eb75a7d751 100644 --- a/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts +++ b/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts @@ -22,4 +22,5 @@ export class SubscriberSessionResponseDto { readonly isDevelopmentMode: boolean; readonly applicationIdentifier?: string; readonly schedule?: Schedule; + readonly contextKeys?: string[]; } diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index 5f51c5d3793..eaae6db79d8 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -275,6 +275,7 @@ export class Session { maxSnoozeDurationHours, isDevelopmentMode: environment.name.toLowerCase() !== 'production', schedule, + contextKeys, }; } diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index ee5e624c937..84f96762b31 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -1,6 +1,7 @@ import type { ActionTypeEnum, ChannelPreference, + Context, DefaultSchedule, InboxNotification, NotificationFilter, @@ -31,17 +32,20 @@ export class InboxService { subscriberHash, subscriber, defaultSchedule, + context, }: { applicationIdentifier?: string; subscriberHash?: string; subscriber?: Subscriber; defaultSchedule?: DefaultSchedule; + context?: Context; }): Promise { const response = (await this.#httpClient.post(`${INBOX_ROUTE}/session`, { applicationIdentifier, subscriberHash, subscriber, defaultSchedule, + context, })) as Session; this.#httpClient.setAuthorizationToken(response.token); this.#httpClient.setKeylessHeader(response.applicationIdentifier); diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 113db182200..91a5d2cf7e1 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -3,6 +3,7 @@ export { Novu } from './novu'; export { ChannelPreference, ChannelType, + Context, DaySchedule, DefaultSchedule, FiltersCountResponse, diff --git a/packages/js/src/novu.ts b/packages/js/src/novu.ts index fe8ffccfec0..765b448cff3 100644 --- a/packages/js/src/novu.ts +++ b/packages/js/src/novu.ts @@ -5,7 +5,7 @@ import { Notifications } from './notifications'; import { Preferences } from './preferences'; import { Session } from './session'; import type { NovuOptions, Subscriber } from './types'; -import { buildSubscriber } from './ui/internal'; +import { buildContextKey, buildSubscriber } from './ui/internal'; import { createSocket } from './ws'; import type { BaseSocketInterface } from './ws/base-socket'; @@ -33,6 +33,14 @@ export class Novu implements Pick { return this.#session.subscriberId; } + public get context() { + return this.#session.context; + } + + public get contextKey() { + return buildContextKey(this.#session.context); + } + constructor(options: NovuOptions) { this.#inboxService = new InboxService({ apiUrl: options.apiUrl || options.backendUrl, @@ -45,6 +53,7 @@ export class Novu implements Pick { subscriberHash: options.subscriberHash, subscriber: buildSubscriber({ subscriberId: options.subscriberId, subscriber: options.subscriber }), defaultSchedule: options.defaultSchedule, + context: options.context, }, this.#inboxService, this.#emitter diff --git a/packages/js/src/session/session.ts b/packages/js/src/session/session.ts index 99a5d2946ef..2976d64a2e5 100644 --- a/packages/js/src/session/session.ts +++ b/packages/js/src/session/session.ts @@ -26,6 +26,10 @@ export class Session { return this.#options.subscriber?.subscriberId; } + public get context() { + return this.#options.context; + } + private handleApplicationIdentifier(method: 'get' | 'store' | 'delete', identifier?: string): string | null { if (typeof window === 'undefined' || !window.localStorage) { return null; @@ -64,7 +68,7 @@ export class Session { if (options) { this.#options = options; } - const { subscriber, subscriberHash, applicationIdentifier, defaultSchedule } = this.#options; + const { subscriber, subscriberHash, applicationIdentifier, defaultSchedule, context } = this.#options; let currentTimezone; if (isBrowser()) { currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -90,6 +94,7 @@ export class Session { timezone: subscriber?.timezone ?? currentTimezone, }, defaultSchedule, + context, }); if (response?.applicationIdentifier?.startsWith('pk_keyless_')) { diff --git a/packages/js/src/session/types.ts b/packages/js/src/session/types.ts index 9db5f9e9947..936c2b47279 100644 --- a/packages/js/src/session/types.ts +++ b/packages/js/src/session/types.ts @@ -1,4 +1,4 @@ -import { DefaultSchedule, Subscriber } from '../types'; +import { Context, DefaultSchedule, Subscriber } from '../types'; export type KeylessInitializeSessionArgs = {} & { [K in string]?: never }; // empty object,disallows all unknown keys @@ -9,4 +9,5 @@ export type InitializeSessionArgs = subscriber: Subscriber; subscriberHash?: string; defaultSchedule?: DefaultSchedule; + context?: Context; }; diff --git a/packages/js/src/types.ts b/packages/js/src/types.ts index 01be81ffbd3..2acf46f23af 100644 --- a/packages/js/src/types.ts +++ b/packages/js/src/types.ts @@ -86,6 +86,7 @@ export type Session = { isDevelopmentMode: boolean; maxSnoozeDurationHours: number; applicationIdentifier?: string; + contextKeys?: string[]; }; export type Subscriber = { @@ -204,6 +205,15 @@ export type DefaultSchedule = { weeklySchedule?: WeeklySchedule; }; +export type ContextValue = + | string + | { + id: string; + data?: Record; + }; + +export type Context = Partial>; + export type PreferencesResponse = { level: PreferenceLevel; enabled: boolean; @@ -247,6 +257,7 @@ export type StandardNovuOptions = { socketUrl?: string; useCache?: boolean; defaultSchedule?: DefaultSchedule; + context?: Context; } & ( | { // TODO: Backward compatibility support - remove in future versions (see NV-5801) diff --git a/packages/js/src/ui/context/InboxContext.tsx b/packages/js/src/ui/context/InboxContext.tsx index 9428a64b9c0..bf6c8a4652e 100644 --- a/packages/js/src/ui/context/InboxContext.tsx +++ b/packages/js/src/ui/context/InboxContext.tsx @@ -34,6 +34,7 @@ type InboxContextType = { isSnoozeEnabled: Accessor; isKeyless: Accessor; applicationIdentifier: Accessor; + contextKeys: Accessor; }; const InboxContext = createContext(undefined); @@ -77,6 +78,7 @@ export const InboxProvider = (props: InboxProviderProps) => { ); const [isKeyless, setIsKeyless] = createSignal(false); const [applicationIdentifier, setApplicationIdentifier] = createSignal(null); + const [contextKeys, setContextKeys] = createSignal(undefined); const [preferenceGroups, setPreferenceGroups] = createSignal(props.preferenceGroups); const [preferencesSort, setPreferencesSort] = createSignal(props.preferencesSort); @@ -141,6 +143,7 @@ export const InboxProvider = (props: InboxProviderProps) => { setHideBranding(data.removeNovuBranding); setIsDevelopmentMode(data.isDevelopmentMode); setMaxSnoozeDurationHours(data.maxSnoozeDurationHours); + setContextKeys(data.contextKeys); if (data.isDevelopmentMode && !props.applicationIdentifier) { setIsKeyless(!data.applicationIdentifier || !!identifier?.startsWith('pk_keyless_')); @@ -174,6 +177,7 @@ export const InboxProvider = (props: InboxProviderProps) => { isSnoozeEnabled, isKeyless, applicationIdentifier, + contextKeys, }} > {props.children} diff --git a/packages/js/src/ui/helpers/useWebSocketEvent.ts b/packages/js/src/ui/helpers/useWebSocketEvent.ts index 9c4bde69ca2..19d087bec34 100644 --- a/packages/js/src/ui/helpers/useWebSocketEvent.ts +++ b/packages/js/src/ui/helpers/useWebSocketEvent.ts @@ -12,7 +12,7 @@ export const useWebSocketEvent = ({ eventHandler: (args: Events[E]) => void; }) => { const novu = useNovu(); - const channelName = `nv_ws_connection:a=${novu.applicationIdentifier}:s=${novu.subscriberId}:e=${webSocketEvent}`; + const channelName = `nv_ws_connection:a=${novu.applicationIdentifier}:s=${novu.subscriberId}:c=${novu.contextKey}:e=${webSocketEvent}`; const { postMessage } = useBrowserTabsChannel({ channelName, onMessage }); diff --git a/packages/js/src/ui/internal/buildContextKey.ts b/packages/js/src/ui/internal/buildContextKey.ts new file mode 100644 index 00000000000..492473813bf --- /dev/null +++ b/packages/js/src/ui/internal/buildContextKey.ts @@ -0,0 +1,30 @@ +import { Context } from '../../types'; + +/** + * Builds a compact, stable string key from context objects by extracting only type:id pairs. + * + * This avoids including large `data` payloads in: + * - React dependency arrays (useMemo) + * - Web Locks API channel names (prevents duplicate subscriptions) + * + * @example + * buildContextKey({ tenant: { id: "inbox-1", data: {...} } }) // "tenant:inbox-1" + * buildContextKey({ tenant: "inbox-1" }) // "tenant:inbox-1" + * buildContextKey(undefined) // "" + */ +export function buildContextKey(context: Context | undefined): string { + if (!context) { + return ''; + } + + const keys: string[] = []; + for (const [type, value] of Object.entries(context)) { + if (value) { + const id = typeof value === 'string' ? value : value.id; + keys.push(`${type}:${id}`); + } + } + + // Sort for consistency (order shouldn't matter) + return keys.sort().join(','); +} diff --git a/packages/js/src/ui/internal/index.ts b/packages/js/src/ui/internal/index.ts index 7b65069c2b3..6d6fc84f7ec 100644 --- a/packages/js/src/ui/internal/index.ts +++ b/packages/js/src/ui/internal/index.ts @@ -1,2 +1,3 @@ +export * from './buildContextKey'; export * from './buildSubscriber'; export * from './parseMarkdown'; diff --git a/packages/react/src/components/Inbox.tsx b/packages/react/src/components/Inbox.tsx index 8d2945e561a..d10ef6f023f 100644 --- a/packages/react/src/components/Inbox.tsx +++ b/packages/react/src/components/Inbox.tsx @@ -113,6 +113,7 @@ export const Inbox = React.memo((props: InboxProps) => { socketUrl: props.socketUrl, subscriber, defaultSchedule: props.defaultSchedule, + context: props.context, } satisfies StandardNovuOptions; return ( @@ -139,6 +140,7 @@ const InboxChild = withRenderer( socketUrl, subscriber, defaultSchedule, + context, } = props; const novu = useNovu(); @@ -158,6 +160,7 @@ const InboxChild = withRenderer( socketUrl, subscriber: buildSubscriber({ subscriberId, subscriber }), defaultSchedule, + context, }, }; }, [ @@ -173,6 +176,7 @@ const InboxChild = withRenderer( backendUrl, socketUrl, subscriber, + context, ]); if (isWithChildrenProps(props)) { diff --git a/packages/react/src/hooks/NovuProvider.tsx b/packages/react/src/hooks/NovuProvider.tsx index 49fd62dea3c..84f210edd64 100644 --- a/packages/react/src/hooks/NovuProvider.tsx +++ b/packages/react/src/hooks/NovuProvider.tsx @@ -53,6 +53,7 @@ export const InternalNovuProvider = (props: NovuProviderProps & { userAgentType: useCache, userAgentType, defaultSchedule, + context, } = props; const novu = useMemo( @@ -67,8 +68,9 @@ export const InternalNovuProvider = (props: NovuProviderProps & { userAgentType: __userAgent: `${baseUserAgent} ${userAgentType}`, subscriber: subscriberObj, defaultSchedule, + context, }), - [applicationIdentifier, subscriberHash, backendUrl, apiUrl, socketUrl, useCache, userAgentType] + [applicationIdentifier, subscriberHash, backendUrl, apiUrl, socketUrl, useCache, userAgentType, context] ); useEffect(() => { diff --git a/packages/react/src/hooks/internal/useWebsocketEvent.ts b/packages/react/src/hooks/internal/useWebsocketEvent.ts index b787abebe6a..fca8277d529 100644 --- a/packages/react/src/hooks/internal/useWebsocketEvent.ts +++ b/packages/react/src/hooks/internal/useWebsocketEvent.ts @@ -12,7 +12,7 @@ export const useWebSocketEvent = ({ eventHandler: (args: Events[E]) => void; }) => { const novu = useNovu(); - const channelName = `nv_ws_connection:a=${novu.applicationIdentifier}:s=${novu.subscriberId}:e=${webSocketEvent}`; + const channelName = `nv_ws_connection:a=${novu.applicationIdentifier}:s=${novu.subscriberId}:c=${novu.contextKey}:e=${webSocketEvent}`; const { postMessage } = useBrowserTabsChannel({ channelName, diff --git a/packages/react/src/utils/types.ts b/packages/react/src/utils/types.ts index 4a077b733d8..1870cc8af02 100644 --- a/packages/react/src/utils/types.ts +++ b/packages/react/src/utils/types.ts @@ -1,4 +1,4 @@ -import type { DefaultSchedule, Subscriber, UnreadCount } from '@novu/js'; +import type { Context, DefaultSchedule, Subscriber, UnreadCount } from '@novu/js'; import type { IconKey, InboxProps, @@ -66,6 +66,7 @@ type StandardBaseProps = { preferencesSort?: PreferencesSort; defaultSchedule?: DefaultSchedule; routerPush?: RouterPush; + context?: Context; } & ( | { // TODO: Backward compatibility support - remove in future versions (see NV-5801)