-
+
+ setTenant(value as typeof tenant)}
+ >
+ {Object.values(Tenants).map((tenant) => (
+
+ {TenantLabels[tenant]}
+
+ ))}
+
+
You have {metadata.unread_count} unread items
@@ -82,14 +88,16 @@ export default function Headless() {
if (isLoading || !userId) {
return (
-
-
-
+
+
);
}
diff --git a/examples/nextjs-example/pages/index.tsx b/examples/nextjs-example/pages/index.tsx
index 1f15e76a..efed3bcf 100644
--- a/examples/nextjs-example/pages/index.tsx
+++ b/examples/nextjs-example/pages/index.tsx
@@ -1,11 +1,13 @@
-import { Box, Flex, Heading, Icon, Link, Select, Text } from "@chakra-ui/react";
import {
KnockFeedProvider,
KnockProvider,
NotificationFeedContainer,
} from "@knocklabs/react";
+import { Icon, Lucide } from "@telegraph/icon";
+import { Box, Stack } from "@telegraph/layout";
+import { Select } from "@telegraph/select";
+import { Heading, Text } from "@telegraph/typography";
import { useCallback, useState } from "react";
-import { IoDocument, IoLogoGithub } from "react-icons/io5";
import NotificationFeed from "../components/NotificationFeed";
import NotificationToasts from "../components/NotificationToasts";
@@ -49,86 +51,98 @@ export default function Home() {
defaultFeedOptions={{ tenant }}
>
-
-
-
-
+
+
+
+
React in-app notifications example
-
-
+
This is an example application to show in-app notifications{" "}
-
- powered by Knock
-
+
+
+ powered by Knock
+
+
.
-
-
-
-
-
+
+
+
+
Send an in-app notification
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
- Github repo
-
-
-
-
- Documentation
-
-
-
- Powered by Knock
-
-
+
+
+
+
+
+
+ Github repo
+
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+ Powered by{" "}
+
+ Knock
+
+
+
+
+
diff --git a/examples/nextjs-example/styles/example-specific-styles.css b/examples/nextjs-example/styles/example-specific-styles.css
new file mode 100644
index 00000000..e14d960c
--- /dev/null
+++ b/examples/nextjs-example/styles/example-specific-styles.css
@@ -0,0 +1,18 @@
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/examples/nextjs-example/styles/globals.css b/examples/nextjs-example/styles/globals.css
new file mode 100644
index 00000000..f3b1ae2d
--- /dev/null
+++ b/examples/nextjs-example/styles/globals.css
@@ -0,0 +1,5 @@
+@import "@telegraph/tokens";
+@import "@telegraph/layout";
+@import "@telegraph/button";
+@import "@telegraph/typography";
+@import "@telegraph/icon";
diff --git a/examples/slack-kit-example/app/examine-channel-data/page.tsx b/examples/slack-kit-example/app/examine-channel-data/page.tsx
index b1a50a3c..9f3030b8 100644
--- a/examples/slack-kit-example/app/examine-channel-data/page.tsx
+++ b/examples/slack-kit-example/app/examine-channel-data/page.tsx
@@ -23,7 +23,8 @@ export default async function Page() {
access_token
and Slack channel
connections as channel data on the{" "}
{tenant}
tenant and{" "}
-
{objectId}
object recipient.{" "}
+
{objectId}
object
+ recipient.{" "}
We've fetched this channel data for you from the resources you
diff --git a/package.json b/package.json
index 89eec117..0a51816e 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,6 @@
"@knocklabs/typescript-config": "workspace:^",
"@manypkg/cli": "^0.22.0",
"prettier": "^3.4.2",
- "turbo": "^2.3.3"
+ "turbo": "^2.4.2"
}
}
diff --git a/packages/client/README.md b/packages/client/README.md
index 04c42a9c..da6d3157 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -111,21 +111,18 @@ const { items } = feedClient.store.getState();
### Reading the feed store state (in React)
```typescript
-// The feed store uses zustand
-import create from "zustand";
-
// Initialize the feed as in above examples
const feedClient = knockClient.feeds.initialize(
process.env.KNOCK_FEED_CHANNEL_ID,
);
-const useFeedStore = create(feedClient.store);
+const feedStore = feedClient.store;
// Retrieves all of the items
-const items = useFeedStore((state) => state.items);
+const items = feedStore.getState().items;
// Retrieve the badge counts
-const meta = useFeedStore((state) => state.metadata);
+const meta = feedStore.getState().metadata;
```
### Marking items as read, seen, or archived
diff --git a/packages/client/package.json b/packages/client/package.json
index 11ef71e2..5d3f7fb0 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@knocklabs/client",
- "version": "0.11.4",
+ "version": "0.12.5",
"description": "The clientside library for interacting with Knock",
"homepage": "https://github.com/knocklabs/javascript/tree/main/packages/client",
"author": "@knocklabs",
@@ -77,6 +77,6 @@
"eventemitter2": "^6.4.5",
"jwt-decode": "^4.0.0",
"phoenix": "1.7.19",
- "zustand": "^3.7.2"
+ "zustand": "^5.0.3"
}
}
diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts
index b83d9501..48ce4144 100644
--- a/packages/client/src/clients/feed/feed.ts
+++ b/packages/client/src/clients/feed/feed.ts
@@ -1,7 +1,7 @@
import { GenericData } from "@knocklabs/types";
import EventEmitter from "eventemitter2";
import { Channel } from "phoenix";
-import { StoreApi } from "zustand";
+import type { StoreApi, UseBoundStore } from "zustand";
import Knock from "../../knock";
import { NetworkStatus, isRequestInFlight } from "../../networkStatus";
@@ -16,6 +16,7 @@ import {
FeedMetadata,
FeedResponse,
FetchFeedOptions,
+ FetchFeedOptionsForRequest,
} from "./interfaces";
import createStore from "./store";
import {
@@ -28,6 +29,7 @@ import {
FeedRealTimeCallback,
FeedStoreState,
} from "./types";
+import { getFormattedTriggerData } from "./utils";
// Default options to apply
const feedClientDefaults: Pick = {
@@ -48,7 +50,7 @@ class Feed {
private visibilityChangeListenerConnected: boolean = false;
// The raw store instance, used for binding in React and other environments
- public store: StoreApi;
+ public store: UseBoundStore>;
constructor(
readonly knock: Knock,
@@ -487,10 +489,16 @@ class Feed {
// Set the loading type based on the request type it is
state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading);
+ const formattedTriggerData = getFormattedTriggerData({
+ ...this.defaultOptions,
+ ...options,
+ });
+
// Always include the default params, if they have been set
- const queryParams = {
+ const queryParams: FetchFeedOptionsForRequest = {
...this.defaultOptions,
...options,
+ trigger_data: formattedTriggerData,
// Unset options that should not be sent to the API
__loadingType: undefined,
__fetchSource: undefined,
diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts
index c8931953..ae8c8b64 100644
--- a/packages/client/src/clients/feed/interfaces.ts
+++ b/packages/client/src/clients/feed/interfaces.ts
@@ -6,6 +6,12 @@ import { NotificationSource } from "../messages/interfaces";
// Specific feed interfaces
+// `trigger_data` may only specify flat key-value pairs, not nested objects
+// Specifying a nested object will result in a 422 "invalid_params" error
+export interface TriggerData extends GenericData {
+ [key: string]: string | number | boolean;
+}
+
export interface FeedClientOptions {
before?: string;
after?: string;
@@ -22,7 +28,7 @@ export interface FeedClientOptions {
// Optionally scope to a given archived status (defaults to `exclude`)
archived?: "include" | "exclude" | "only";
// Optionally scope all notifications that contain this argument as part of their trigger payload
- trigger_data?: GenericData;
+ trigger_data?: TriggerData;
// Optionally enable cross browser feed updates for this feed
__experimentalCrossBrowserUpdates?: boolean;
// Optionally automatically manage socket connections on changes to tab visibility (defaults to `false`)
@@ -37,6 +43,22 @@ export type FetchFeedOptions = {
__fetchSource?: "socket" | "http";
} & Omit;
+// The final data shape that is sent to the API
+// Should match types here: https://docs.knock.app/reference#get-feed
+export type FetchFeedOptionsForRequest = Omit<
+ FeedClientOptions,
+ "trigger_data"
+> & {
+ // Formatted trigger data into a string
+ trigger_data?: string;
+ // Unset options that should not be sent to the API
+ __loadingType: undefined;
+ __fetchSource: undefined;
+ __experimentalCrossBrowserUpdates: undefined;
+ auto_manage_socket_connection: undefined;
+ auto_manage_socket_connection_delay: undefined;
+};
+
export interface ContentBlockBase {
name: string;
type: "markdown" | "text" | "button_set";
diff --git a/packages/client/src/clients/feed/store.ts b/packages/client/src/clients/feed/store.ts
index 78b98bd2..89c6d881 100644
--- a/packages/client/src/clients/feed/store.ts
+++ b/packages/client/src/clients/feed/store.ts
@@ -1,5 +1,5 @@
import { GenericData } from "@knocklabs/types";
-import zustand from "zustand/vanilla";
+import { create } from "zustand";
import { NetworkStatus } from "../../networkStatus";
@@ -7,12 +7,6 @@ import { FeedItem } from "./interfaces";
import { FeedStoreState } from "./types";
import { deduplicateItems, sortItems } from "./utils";
-// Get the correct Zustand function. Caused by some issues in v3 exports
-// https://github.com/pmndrs/zustand/issues/334
-const create: typeof zustand =
- // @ts-expect-error workaround for issue above
- typeof zustand === "function" ? zustand : zustand.default;
-
function processItems(items: FeedItem[]) {
const deduped = deduplicateItems(items);
const sorted = sortItems(deduped);
diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts
index 5dec1250..99740692 100644
--- a/packages/client/src/clients/feed/utils.ts
+++ b/packages/client/src/clients/feed/utils.ts
@@ -1,4 +1,4 @@
-import { FeedItem } from "./interfaces";
+import type { FeedClientOptions, FeedItem } from "./interfaces";
export function deduplicateItems(items: FeedItem[]): FeedItem[] {
const seen: Record = {};
@@ -21,3 +21,21 @@ export function sortItems(items: FeedItem[]) {
);
});
}
+
+// If the trigger data is an object, stringify it to conform to API expectations
+// https://docs.knock.app/reference#get-feed
+// We also want to be careful to check for string values already,
+// because this was a bug (KNO-7843) and customers had to manually stringify their trigger data
+export function getFormattedTriggerData(options: FeedClientOptions) {
+ // If the trigger data is an object, stringify it to conform to API expectations
+ if (typeof options?.trigger_data === "object") {
+ return JSON.stringify(options.trigger_data);
+ }
+
+ // For when the trigger data is already formatted as a string by the user
+ if (typeof options?.trigger_data === "string") {
+ return options.trigger_data;
+ }
+
+ return undefined;
+}
diff --git a/packages/expo/README.md b/packages/expo/README.md
index ba0fb07a..b3a39df7 100644
--- a/packages/expo/README.md
+++ b/packages/expo/README.md
@@ -115,7 +115,6 @@ Alternatively, if you don't want to use our components you can render the feed i
```jsx
import { useAuthenticatedKnockClient, useNotifications } from "@knocklabs/expo";
-import create from "zustand";
const YourAppLayout = () => {
const knockClient = useAuthenticatedKnockClient(
@@ -128,8 +127,8 @@ const YourAppLayout = () => {
process.env.KNOCK_FEED_ID,
);
- const useNotificationStore = create(notificationFeed.store);
- const { metadata } = useNotificationStore();
+ const notificationStore = notificationFeed.store;
+ const { metadata } = notificationStore.getState();
useEffect(() => {
notificationFeed.fetch();
diff --git a/packages/react-core/package.json b/packages/react-core/package.json
index da4a1e81..6047906b 100644
--- a/packages/react-core/package.json
+++ b/packages/react-core/package.json
@@ -2,7 +2,7 @@
"name": "@knocklabs/react-core",
"description": "A set of React components to build notification experiences powered by Knock",
"author": "@knocklabs",
- "version": "0.4.0",
+ "version": "0.5.1",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.mjs",
@@ -46,16 +46,19 @@
"url": "https://github.com/knocklabs/javascript/issues"
},
"peerDependencies": {
- "react": "^16.11.0 || ^17.0.0 || ^18.0.0"
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"dependencies": {
"@knocklabs/client": "workspace:^",
"date-fns": "^4.0.0",
- "swr": "^2.2.5",
- "zustand": "^3.7.2"
+ "deep-equal": "^2.2.3",
+ "swr": "^2.3.2",
+ "zustand": "^5.0.3"
},
"devDependencies": {
- "@testing-library/react": "^14.2.0",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.2.0",
+ "@types/deep-equal": "^1.0.4",
"@types/react": "^18.3.6",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.24.0",
@@ -66,6 +69,7 @@
"eslint-plugin-react-refresh": "^0.4.14",
"jsdom": "^25.0.1",
"react": "^18.2.0",
+ "react-dom": "^18.2.0",
"rimraf": "^6.0.1",
"rollup-plugin-execute": "^1.1.1",
"typescript": "^5.7.3",
diff --git a/packages/react-core/src/modules/core/hooks/useStableOptions.ts b/packages/react-core/src/modules/core/hooks/useStableOptions.ts
index 5ec83292..e60c56aa 100644
--- a/packages/react-core/src/modules/core/hooks/useStableOptions.ts
+++ b/packages/react-core/src/modules/core/hooks/useStableOptions.ts
@@ -1,5 +1,5 @@
+import deepEqual from "deep-equal";
import { useMemo, useRef } from "react";
-import shallow from "zustand/shallow";
export default function useStableOptions(options: T): T {
const optionsRef = useRef();
@@ -7,7 +7,12 @@ export default function useStableOptions(options: T): T {
return useMemo(() => {
const currentOptions = optionsRef.current;
- if (currentOptions && shallow(options, currentOptions)) {
+ const objectsHaventChanged = deepEqual(options, currentOptions, {
+ // use strict equality (===) to compare leaf nodes
+ strict: true,
+ });
+
+ if (currentOptions && objectsHaventChanged) {
return currentOptions;
}
diff --git a/packages/react-core/src/modules/feed/context/KnockFeedProvider.tsx b/packages/react-core/src/modules/feed/context/KnockFeedProvider.tsx
index 4e87bfcd..8ff1fe53 100644
--- a/packages/react-core/src/modules/feed/context/KnockFeedProvider.tsx
+++ b/packages/react-core/src/modules/feed/context/KnockFeedProvider.tsx
@@ -5,7 +5,7 @@ import Knock, {
} from "@knocklabs/client";
import * as React from "react";
import { PropsWithChildren } from "react";
-import { UseBoundStore } from "zustand";
+import type { StoreApi, UseBoundStore } from "zustand";
import { useKnockClient } from "../../core";
import { ColorMode } from "../../core/constants";
@@ -16,7 +16,7 @@ import useNotifications from "../hooks/useNotifications";
export interface KnockFeedProviderState {
knock: Knock;
feedClient: Feed;
- useFeedStore: UseBoundStore;
+ useFeedStore: UseBoundStore>;
colorMode: ColorMode;
}
diff --git a/packages/react-core/src/modules/feed/hooks/useNotificationStore.ts b/packages/react-core/src/modules/feed/hooks/useNotificationStore.ts
index 9f17fcc1..0f24f797 100644
--- a/packages/react-core/src/modules/feed/hooks/useNotificationStore.ts
+++ b/packages/react-core/src/modules/feed/hooks/useNotificationStore.ts
@@ -1,44 +1,55 @@
-import { Feed, FeedStoreState } from "@knocklabs/client";
-import * as React from "react";
-import type { DispatchWithoutAction } from "react";
-import create, { StateSelector } from "zustand";
+import { Feed, type FeedStoreState } from "@knocklabs/client";
-const useIsomorphicLayoutEffect =
- typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
-
-// A hook designed to create a `UseBoundStore` instance
+// A hook designed to create a `UseBoundStore` instance.
+// We used to have to do some extra work to do this, but
+// with zustand updated we can just use the feedClient.store
+// directly.
function useCreateNotificationStore(feedClient: Feed) {
- const useStore = React.useMemo(
- () => create(feedClient.store),
- [feedClient],
- );
-
- // Warning: this is a hack that will cause any components downstream to re-render
- // as a result of the store updating.
- const [, forceUpdate] = React.useReducer((c) => c + 1, 0) as [
- never,
- () => void,
- ];
-
- useIsomorphicLayoutEffect(() => {
- const rerender = forceUpdate as DispatchWithoutAction;
- const unsubscribe = feedClient.store.subscribe(rerender);
+ return feedClient.store;
+}
- rerender();
+/**
+ * Below we do some typing to specify that if a selector is provided,
+ * the return type will be the type returned by the selector.
+ *
+ * This is important because the store state type is not always the same as the
+ * return type of the selector.
+ *
+ */
- return unsubscribe;
- }, [feedClient]);
+type StateSelector = (state: T) => U;
+type FeedStoreStateSelector = StateSelector;
- return useStore;
-}
+// Function overload for when no selector is provided
+function useNotificationStore(feedClient: Feed): FeedStoreState;
-// A hook used to access content *within* the notification store
-function useNotificationStore(
+// Function overload for when a selector is provided
+function useNotificationStore(
+ feedClient: Feed,
+ selector: FeedStoreStateSelector,
+): T;
+
+/**
+ * A hook used to access content within the notification store.
+ *
+ * A selector can be used to access a subset of the store state.
+ *
+ * @example
+ *
+ * ```ts
+ * const { items, metadata } = useNotificationStore(feedClient, (state) => ({
+ * items: state.items,
+ * metadata: state.metadata,
+ * }));
+ * ```
+ */
+function useNotificationStore(
feedClient: Feed,
- selector?: StateSelector,
-) {
+ selector?: FeedStoreStateSelector,
+): T | FeedStoreState {
const useStore = useCreateNotificationStore(feedClient);
- return useStore(selector || feedClient.store.getState);
+ const storeState = useStore();
+ return selector ? selector(storeState) : storeState;
}
export { useCreateNotificationStore };
diff --git a/packages/react-native/README.md b/packages/react-native/README.md
index b4c0a704..57ab2523 100644
--- a/packages/react-native/README.md
+++ b/packages/react-native/README.md
@@ -64,7 +64,6 @@ import {
useAuthenticatedKnockClient,
useNotifications,
} from "@knocklabs/react-native";
-import create from "zustand";
const YourAppLayout = () => {
const knockClient = useAuthenticatedKnockClient(
@@ -77,8 +76,8 @@ const YourAppLayout = () => {
process.env.KNOCK_FEED_ID,
);
- const useNotificationStore = create(notificationFeed.store);
- const { metadata } = useNotificationStore();
+ const notificationStore = notificationFeed.store;
+ const { metadata } = notificationStore.getState();
useEffect(() => {
notificationFeed.fetch();
diff --git a/packages/react/README.md b/packages/react/README.md
index a89070ec..cf0b9fc3 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -82,7 +82,6 @@ import {
useAuthenticatedKnockClient,
useNotifications,
} from "@knocklabs/react";
-import create from "zustand";
const YourAppLayout = () => {
const knockClient = useAuthenticatedKnockClient(
@@ -95,8 +94,8 @@ const YourAppLayout = () => {
process.env.KNOCK_FEED_ID,
);
- const useNotificationStore = create(notificationFeed.store);
- const { metadata } = useNotificationStore();
+ const notificationStore = notificationFeed.store;
+ const { metadata } = notificationStore.getState();
useEffect(() => {
notificationFeed.fetch();
diff --git a/packages/react/package.json b/packages/react/package.json
index 8174bd7a..ce65a27f 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -2,7 +2,7 @@
"name": "@knocklabs/react",
"description": "A set of React components to build notification experiences powered by Knock",
"author": "@knocklabs",
- "version": "0.5.0",
+ "version": "0.6.0",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.mjs",
@@ -47,8 +47,8 @@
"url": "https://github.com/knocklabs/javascript/issues"
},
"peerDependencies": {
- "react": "^16.11.0 || ^17.0.0 || ^18.0.0",
- "react-dom": "^16.11.0 || ^17.0.0 || ^18.0.0"
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"dependencies": {
"@knocklabs/client": "workspace:^",
@@ -58,12 +58,11 @@
"@telegraph/icon": "^0.0.46",
"@telegraph/layout": "^0.1.12",
"@telegraph/typography": "^0.1.12",
- "lodash.debounce": "^4.0.8",
- "react-popper": "^2.3.0",
- "react-popper-tooltip": "^4.4.2"
+ "lodash.debounce": "^4.0.8"
},
"devDependencies": {
- "@testing-library/react": "^14.2.0",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.2.0",
"@types/eslint-plugin-jsx-a11y": "^6",
"@types/lodash.debounce": "^4.0.9",
"@types/react": "^18.3.6",
diff --git a/packages/react/src/modules/feed/components/NotificationCell/ArchiveButton.tsx b/packages/react/src/modules/feed/components/NotificationCell/ArchiveButton.tsx
index 76fb9d2c..12741630 100644
--- a/packages/react/src/modules/feed/components/NotificationCell/ArchiveButton.tsx
+++ b/packages/react/src/modules/feed/components/NotificationCell/ArchiveButton.tsx
@@ -1,7 +1,7 @@
import { FeedItem } from "@knocklabs/client";
import { useKnockFeed, useTranslations } from "@knocklabs/react-core";
-import React, { MouseEvent, useCallback } from "react";
-import { usePopperTooltip } from "react-popper-tooltip";
+import { createPopper } from "@popperjs/core";
+import { MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import { CloseCircle } from "../../../core/components/Icons";
@@ -12,6 +12,9 @@ export interface ArchiveButtonProps {
const ArchiveButton: React.FC = ({ item }) => {
const { colorMode, feedClient } = useKnockFeed();
const { t } = useTranslations();
+ const [visible, setVisible] = useState(false);
+ const triggerRef = useRef(null);
+ const tooltipRef = useRef(null);
const onClick = useCallback(
(e: MouseEvent) => {
@@ -25,13 +28,36 @@ const ArchiveButton: React.FC = ({ item }) => {
[item],
);
- const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
- usePopperTooltip({ placement: "top-end" });
+ useEffect(() => {
+ if (triggerRef.current && tooltipRef.current && visible) {
+ const popperInstance = createPopper(
+ triggerRef.current,
+ tooltipRef.current,
+ {
+ placement: "top-end",
+ modifiers: [
+ {
+ name: "offset",
+ options: {
+ offset: [0, 8],
+ },
+ },
+ ],
+ },
+ );
+
+ return () => {
+ popperInstance.destroy();
+ };
+ }
+ }, [visible]);
return (