@knocklabs/react-core 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +5 -2
  3. package/src/index.ts +4 -0
  4. package/src/modules/core/constants.ts +8 -0
  5. package/src/modules/core/context/KnockProvider.tsx +82 -0
  6. package/src/modules/core/context/index.ts +1 -0
  7. package/src/modules/core/hooks/index.ts +1 -0
  8. package/src/modules/core/hooks/useAuthenticatedKnockClient.ts +50 -0
  9. package/src/modules/core/index.ts +4 -0
  10. package/src/modules/core/utils.ts +74 -0
  11. package/src/modules/feed/context/KnockFeedProvider.tsx +68 -0
  12. package/src/modules/feed/context/index.ts +1 -0
  13. package/src/modules/feed/hooks/index.ts +2 -0
  14. package/src/modules/feed/hooks/useFeedSettings.ts +44 -0
  15. package/src/modules/feed/hooks/useNotifications.ts +32 -0
  16. package/src/modules/feed/index.ts +2 -0
  17. package/src/modules/i18n/context/KnockI18nProvider.tsx +16 -0
  18. package/src/modules/i18n/context/index.ts +1 -0
  19. package/src/modules/i18n/hooks/index.ts +1 -0
  20. package/src/modules/i18n/hooks/useTranslations.ts +15 -0
  21. package/src/modules/i18n/index.ts +3 -0
  22. package/src/modules/i18n/languages/de.ts +40 -0
  23. package/src/modules/i18n/languages/en.ts +39 -0
  24. package/src/modules/i18n/languages/index.ts +43 -0
  25. package/src/modules/slack/constants.ts +12 -0
  26. package/src/modules/slack/context/KnockSlackProvider.tsx +81 -0
  27. package/src/modules/slack/context/index.ts +1 -0
  28. package/src/modules/slack/hooks/index.ts +4 -0
  29. package/src/modules/slack/hooks/useConnectedSlackChannels.ts +105 -0
  30. package/src/modules/slack/hooks/useSlackAuth.ts +84 -0
  31. package/src/modules/slack/hooks/useSlackChannels.ts +91 -0
  32. package/src/modules/slack/hooks/useSlackConnectionStatus.ts +102 -0
  33. package/src/modules/slack/index.ts +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 10b5646: Include src files for react-core
8
+
9
+ ## 0.2.4
10
+
11
+ ### Patch Changes
12
+
13
+ - bc69618: Add react-native to package.json files to fix a bug in our React Native SDK
14
+ - Updated dependencies [bc69618]
15
+ - @knocklabs/client@0.9.3
16
+
3
17
  ## 0.2.3
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -2,22 +2,25 @@
2
2
  "name": "@knocklabs/react-core",
3
3
  "description": "A set of React components to build notification experiences powered by Knock",
4
4
  "author": "@knocklabs",
5
- "version": "0.2.3",
5
+ "version": "0.2.5",
6
6
  "license": "MIT",
7
7
  "main": "dist/cjs/index.js",
8
8
  "module": "dist/esm/index.mjs",
9
9
  "types": "dist/types/index.d.ts",
10
10
  "typings": "dist/types/index.d.ts",
11
+ "react-native": "./src/index.ts",
11
12
  "exports": {
12
13
  ".": {
13
14
  "require": "./dist/cjs/index.js",
14
15
  "import": "./dist/esm/index.mjs",
15
16
  "types": "./dist/types/index.d.ts",
17
+ "react-native": "./src/index.ts",
16
18
  "default": "./dist/cjs/index.js"
17
19
  }
18
20
  },
19
21
  "files": [
20
22
  "dist",
23
+ "src",
21
24
  "README.md"
22
25
  ],
23
26
  "scripts": {
@@ -45,7 +48,7 @@
45
48
  "react": ">=16.8.0"
46
49
  },
47
50
  "dependencies": {
48
- "@knocklabs/client": "^0.9.2",
51
+ "@knocklabs/client": "^0.9.3",
49
52
  "@tanstack/react-query": "^5.18.1",
50
53
  "date-fns": "^3.3.1",
51
54
  "zustand": "^3.7.2"
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./modules/core";
2
+ export * from "./modules/feed";
3
+ export * from "./modules/slack"
4
+ export * from "./modules/i18n";
@@ -0,0 +1,8 @@
1
+ export enum FilterStatus {
2
+ All = "all",
3
+ Read = "read",
4
+ Unseen = "unseen",
5
+ Unread = "unread",
6
+ }
7
+
8
+ export type ColorMode = "light" | "dark";
@@ -0,0 +1,82 @@
1
+ import Knock, { AuthenticateOptions, LogLevel } from "@knocklabs/client";
2
+ import * as React from "react";
3
+
4
+ import { I18nContent, KnockI18nProvider } from "../../i18n";
5
+ import { useAuthenticatedKnockClient } from "../hooks";
6
+
7
+ export interface KnockProviderState {
8
+ knock: Knock;
9
+ }
10
+
11
+ const ProviderStateContext = React.createContext<KnockProviderState | null>(
12
+ null,
13
+ );
14
+
15
+ export interface KnockProviderProps {
16
+ // Knock client props
17
+ apiKey: string;
18
+ host?: string;
19
+ // Authentication props
20
+ userId: string;
21
+ userToken?: string;
22
+ onUserTokenExpiring?: AuthenticateOptions["onUserTokenExpiring"];
23
+ timeBeforeExpirationInMs?: AuthenticateOptions["timeBeforeExpirationInMs"];
24
+
25
+ // Extra options
26
+ children?: React.ReactElement;
27
+
28
+ // i18n translations
29
+ i18n?: I18nContent;
30
+
31
+ logLevel?: LogLevel;
32
+ }
33
+
34
+ export const KnockProvider: React.FC<KnockProviderProps> = ({
35
+ apiKey,
36
+ host,
37
+ logLevel,
38
+ userId,
39
+ userToken,
40
+ onUserTokenExpiring,
41
+ timeBeforeExpirationInMs,
42
+ children,
43
+ i18n,
44
+ }) => {
45
+ // We memoize the options here so that we don't create a new object on every re-render
46
+ // TODO: we probably need to put this optimization into the `useAuthenticatedKnockClient`
47
+ // hook itself to fix this
48
+ const authenticateOptions = React.useMemo(
49
+ () => ({
50
+ host,
51
+ onUserTokenExpiring,
52
+ timeBeforeExpirationInMs,
53
+ logLevel,
54
+ }),
55
+ [host, onUserTokenExpiring, timeBeforeExpirationInMs, logLevel],
56
+ );
57
+
58
+ const knock = useAuthenticatedKnockClient(
59
+ apiKey,
60
+ userId,
61
+ userToken,
62
+ authenticateOptions,
63
+ );
64
+
65
+ return (
66
+ <ProviderStateContext.Provider
67
+ value={{
68
+ knock,
69
+ }}
70
+ >
71
+ <KnockI18nProvider i18n={i18n}>{children}</KnockI18nProvider>
72
+ </ProviderStateContext.Provider>
73
+ );
74
+ };
75
+
76
+ export const useKnockClient = (): Knock => {
77
+ const context = React.useContext(ProviderStateContext) as KnockProviderState;
78
+ if (context === undefined) {
79
+ throw new Error("useKnock must be used within a KnockProvider");
80
+ }
81
+ return context.knock;
82
+ };
@@ -0,0 +1 @@
1
+ export * from "./KnockProvider";
@@ -0,0 +1 @@
1
+ export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient";
@@ -0,0 +1,50 @@
1
+ import Knock, { AuthenticateOptions, KnockOptions } from "@knocklabs/client";
2
+ import React, { useMemo } from "react";
3
+
4
+ function authenticateWithOptions(
5
+ knock: Knock,
6
+ userId: string,
7
+ userToken?: string,
8
+ options: AuthenticateOptions = {},
9
+ ) {
10
+ knock.authenticate(userId, userToken, {
11
+ onUserTokenExpiring: options?.onUserTokenExpiring,
12
+ timeBeforeExpirationInMs: options?.timeBeforeExpirationInMs,
13
+ });
14
+ }
15
+
16
+ function useAuthenticatedKnockClient(
17
+ apiKey: string,
18
+ userId: string,
19
+ userToken?: string,
20
+ options: KnockOptions & AuthenticateOptions = {},
21
+ ) {
22
+ const knockRef = React.useRef<Knock | null>();
23
+
24
+ return useMemo(() => {
25
+ const currentKnock = knockRef.current;
26
+
27
+ // If the userId and the userToken changes then just reauth
28
+ if (
29
+ currentKnock &&
30
+ currentKnock.isAuthenticated() &&
31
+ (currentKnock.userId !== userId || currentKnock.userToken !== userToken)
32
+ ) {
33
+ authenticateWithOptions(currentKnock, userId, userToken, options);
34
+ return currentKnock;
35
+ }
36
+
37
+ if (currentKnock) {
38
+ currentKnock.teardown();
39
+ }
40
+
41
+ // Otherwise instantiate a new Knock client
42
+ const knock = new Knock(apiKey, options);
43
+ authenticateWithOptions(knock, userId, userToken, options);
44
+ knockRef.current = knock;
45
+
46
+ return knock;
47
+ }, [apiKey, userId, userToken, options]);
48
+ }
49
+
50
+ export default useAuthenticatedKnockClient;
@@ -0,0 +1,4 @@
1
+ export * from "./context";
2
+ export * from "./hooks";
3
+ export * from "./constants";
4
+ export * from "./utils";
@@ -0,0 +1,74 @@
1
+ import { FeedClientOptions } from "@knocklabs/client";
2
+ import { intlFormatDistance, parseISO } from "date-fns";
3
+ import { ReactNode } from "react";
4
+
5
+ export function formatBadgeCount(count: number): string | number {
6
+ return count > 9 ? "9+" : count;
7
+ }
8
+
9
+ type FormatTimestampOptions = {
10
+ locale?: string | string[];
11
+ };
12
+
13
+ export function formatTimestamp(
14
+ ts: string,
15
+ options: FormatTimestampOptions = {},
16
+ ) {
17
+ try {
18
+ const parsedTs = parseISO(ts);
19
+ const formatted = intlFormatDistance(parsedTs, new Date(), {
20
+ locale: options.locale,
21
+ });
22
+
23
+ return formatted;
24
+ } catch (e) {
25
+ return ts;
26
+ }
27
+ }
28
+
29
+ export function toSentenceCase(string: string): string {
30
+ return string.charAt(0).toUpperCase() + string.slice(1);
31
+ }
32
+
33
+ export function renderNodeOrFallback(node: ReactNode, fallback: ReactNode) {
34
+ return node !== undefined ? node : fallback;
35
+ }
36
+
37
+ /*
38
+ Used to build a consistent key for the KnockFeedProvider so that React knows when
39
+ to trigger a re-render of the context when a key property changes.
40
+ */
41
+ export function feedProviderKey(
42
+ userFeedId: string,
43
+ options: FeedClientOptions = {},
44
+ ) {
45
+ return [
46
+ userFeedId,
47
+ options.source,
48
+ options.tenant,
49
+ options.has_tenant,
50
+ options.archived,
51
+ ]
52
+ .filter((f) => f !== null && f !== undefined)
53
+ .join("-");
54
+ }
55
+
56
+ /*
57
+ Used to build a consistent key for the KnockSlackProvider so that React knows when
58
+ to trigger a re-render of the context when a key property changes.
59
+ */
60
+ export function slackProviderKey({
61
+ knockSlackChannelId,
62
+ tenant,
63
+ connectionStatus,
64
+ errorLabel,
65
+ }: {
66
+ knockSlackChannelId: string;
67
+ tenant: string;
68
+ connectionStatus: string;
69
+ errorLabel: string | null;
70
+ }) {
71
+ return [knockSlackChannelId, tenant, connectionStatus, errorLabel]
72
+ .filter((f) => f !== null && f !== undefined)
73
+ .join("-");
74
+ }
@@ -0,0 +1,68 @@
1
+ import Knock, {
2
+ Feed,
3
+ FeedClientOptions,
4
+ FeedStoreState,
5
+ } from "@knocklabs/client";
6
+ import * as React from "react";
7
+ import create, { UseBoundStore } from "zustand";
8
+
9
+ import { useKnockClient } from "../../core";
10
+ import { ColorMode } from "../../core/constants";
11
+ import { feedProviderKey } from "../../core/utils";
12
+ import useNotifications from "../hooks/useNotifications";
13
+
14
+ export interface KnockFeedProviderState {
15
+ knock: Knock;
16
+ feedClient: Feed;
17
+ useFeedStore: UseBoundStore<FeedStoreState>;
18
+ colorMode: ColorMode;
19
+ }
20
+
21
+ const FeedStateContext = React.createContext<KnockFeedProviderState | null>(
22
+ null,
23
+ );
24
+
25
+ export interface KnockFeedProviderProps {
26
+ // Feed props
27
+ feedId: string;
28
+
29
+ // Extra options
30
+ children?: React.ReactElement;
31
+ colorMode?: ColorMode;
32
+
33
+ // Feed client options
34
+ defaultFeedOptions?: FeedClientOptions;
35
+ }
36
+
37
+ export const KnockFeedProvider: React.FC<KnockFeedProviderProps> = ({
38
+ feedId,
39
+ children,
40
+ defaultFeedOptions = {},
41
+ colorMode = "light",
42
+ }) => {
43
+ const knock = useKnockClient();
44
+ const feedClient = useNotifications(knock, feedId, defaultFeedOptions);
45
+ const useFeedStore = create<FeedStoreState>(feedClient.store);
46
+
47
+ return (
48
+ <FeedStateContext.Provider
49
+ key={feedProviderKey(feedId, defaultFeedOptions)}
50
+ value={{
51
+ knock,
52
+ feedClient,
53
+ useFeedStore,
54
+ colorMode,
55
+ }}
56
+ >
57
+ {children}
58
+ </FeedStateContext.Provider>
59
+ );
60
+ };
61
+
62
+ export const useKnockFeed = (): KnockFeedProviderState => {
63
+ const context = React.useContext(FeedStateContext);
64
+ if (context === undefined) {
65
+ throw new Error("useKnockFeed must be used within a KnockFeedProvider");
66
+ }
67
+ return context as KnockFeedProviderState;
68
+ };
@@ -0,0 +1 @@
1
+ export * from "./KnockFeedProvider";
@@ -0,0 +1,2 @@
1
+ export { default as useNotifications } from "./useNotifications";
2
+ export { default as useFeedSettings } from "./useFeedSettings";
@@ -0,0 +1,44 @@
1
+ import { Feed } from "@knocklabs/client";
2
+ import { useEffect, useState } from "react";
3
+
4
+ export type FeedSettings = {
5
+ features: {
6
+ branding_required: boolean;
7
+ };
8
+ };
9
+
10
+ function useFeedSettings(feedClient: Feed): {
11
+ settings: FeedSettings | null;
12
+ loading: boolean;
13
+ } {
14
+ const [settings, setSettings] = useState(null);
15
+ const [isLoading, setIsLoading] = useState(false);
16
+
17
+ // TODO: consider moving this into the feed client and into the feed store state when
18
+ // we're using this in other areas of the feed
19
+ useEffect(() => {
20
+ async function getSettings() {
21
+ const knock = feedClient.knock;
22
+ const apiClient = knock.client();
23
+ const feedSettingsPath = `/v1/users/${knock.userId}/feeds/${feedClient.feedId}/settings`;
24
+ setIsLoading(true);
25
+
26
+ const response = await apiClient.makeRequest({
27
+ method: "GET",
28
+ url: feedSettingsPath,
29
+ });
30
+
31
+ if (!response.error) {
32
+ setSettings(response.body);
33
+ }
34
+
35
+ setIsLoading(false);
36
+ }
37
+
38
+ getSettings();
39
+ }, []);
40
+
41
+ return { settings, loading: isLoading };
42
+ }
43
+
44
+ export default useFeedSettings;
@@ -0,0 +1,32 @@
1
+ import Knock, { Feed, FeedClientOptions } from "@knocklabs/client";
2
+ import { useMemo, useRef } from "react";
3
+
4
+ function useNotifications(
5
+ knock: Knock,
6
+ feedChannelId: string,
7
+ options: FeedClientOptions = {},
8
+ ) {
9
+ const feedClientRef = useRef<Feed | null>();
10
+
11
+ return useMemo(() => {
12
+ if (feedClientRef.current) {
13
+ feedClientRef.current.dispose();
14
+ }
15
+
16
+ const feedClient = knock.feeds.initialize(feedChannelId, options);
17
+
18
+ feedClient.listenForUpdates();
19
+ feedClientRef.current = feedClient;
20
+
21
+ return feedClient;
22
+ }, [
23
+ knock,
24
+ feedChannelId,
25
+ options.source,
26
+ options.tenant,
27
+ options.has_tenant,
28
+ options.archived,
29
+ ]);
30
+ }
31
+
32
+ export default useNotifications;
@@ -0,0 +1,2 @@
1
+ export * from "./context";
2
+ export * from "./hooks";
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import { locales, I18nContent } from "../languages";
3
+
4
+ export const I18nContext = React.createContext<I18nContent>(locales.en);
5
+
6
+ interface KnockI18nProviderProps {
7
+ i18n?: I18nContent;
8
+ children: JSX.Element | undefined;
9
+ }
10
+
11
+ export function KnockI18nProvider({
12
+ i18n = locales.en,
13
+ ...props
14
+ }: KnockI18nProviderProps) {
15
+ return <I18nContext.Provider {...props} value={i18n} />;
16
+ }
@@ -0,0 +1 @@
1
+ export * from "./KnockI18nProvider";
@@ -0,0 +1 @@
1
+ export * from "./useTranslations";
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react";
2
+ import { I18nContent, locales } from "../languages";
3
+ import { I18nContext } from "../context/KnockI18nProvider";
4
+
5
+ export function useTranslations() {
6
+ const { translations, locale } = useContext<I18nContent>(I18nContext);
7
+
8
+ return {
9
+ locale,
10
+ t: (key: keyof typeof translations) => {
11
+ // We always use english as the default translation when a key doesn't exist
12
+ return translations[key] || locales.en.translations[key];
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./context";
2
+ export * from "./hooks";
3
+ export * from "./languages";
@@ -0,0 +1,40 @@
1
+ import { I18nContent } from ".";
2
+
3
+ const de: I18nContent = {
4
+ translations: {
5
+ archiveNotification: "Benachrichtigung archivieren",
6
+ markAllAsRead: "Alle als gelesen markieren",
7
+ notifications: "Benachrichtigungen",
8
+ emptyFeedTitle: "Noch keine Benachrichtigungen",
9
+ emptyFeedBody:
10
+ "Wir werden dich benachrichtigen, sobald wir etwas Neues für dich haben.",
11
+ all: "Alle",
12
+ unread: "Ungelesen",
13
+ read: "Gelesen",
14
+ unseen: "Ungesehen",
15
+ slackConnectChannel: "Kanal verbinden",
16
+ slackChannelId: "Slack Kanal ID",
17
+ slackConnecting: "Verbinden mit Slack...",
18
+ slackDisconnecting: "Trennen der Verbindung...",
19
+ slackConnect: "Mit Slack verbinden",
20
+ slackConnected: "Verbunden",
21
+ slackConnectContainerDescription: "Verbinden, um Benachrichtigungen in Ihrem Slack-Arbeitsbereich zu erhalten.",
22
+ slackSearchbarDisconnected: "Slack ist nicht verbunden.",
23
+ slackSearchbarMultipleChannels: "Mehrere Kanäle verbunden",
24
+ slackSearchbarNoChannelsConnected: "Suchkanäle",
25
+ slackSearchbarNoChannelsFound: "Keine schlaffen Kanäle.",
26
+ slackSearchbarChannelsError: "Fehler beim Abrufen von Kanälen.",
27
+ slackSearchChannels: "Suchkanäle",
28
+ slackConnectionErrorExists: "Versuchen Sie, sich erneut mit Slack zu verbinden, um Kanäle in Ihrem Arbeitsbereich zu finden und auszuwählen.",
29
+ slackConnectionErrorOccurred: "Es ist ein Fehler beim Verbinden mit Slack aufgetreten. Versuchen Sie, sich erneut zu verbinden, um Kanäle in Ihrem Arbeitsbereich zu finden und auszuwählen.",
30
+ slackChannelAlreadyConnected: "Fehler: bereits verbunden",
31
+ slackError: "Fehler",
32
+ slackDisconnect: "Trennen Sie die Verbindung.",
33
+ slackChannelSetError: "Fehlereinstellung Kanal.",
34
+ slackAccessTokenNotSet: "Zugriffstoken nicht gesetzt.",
35
+ slackReconnect: "Neu verbinden",
36
+ },
37
+ locale: "de",
38
+ };
39
+
40
+ export default de;
@@ -0,0 +1,39 @@
1
+ import { I18nContent } from ".";
2
+
3
+ const en: I18nContent = {
4
+ translations: {
5
+ archiveNotification: "Archive this notification",
6
+ markAllAsRead: "Mark all as read",
7
+ notifications: "Notifications",
8
+ emptyFeedTitle: "No notifications yet",
9
+ emptyFeedBody: "We'll let you know when we've got something new for you.",
10
+ all: "All",
11
+ unread: "Unread",
12
+ read: "Read",
13
+ unseen: "Unseen",
14
+ slackConnectChannel: "Connect channel",
15
+ slackChannelId: "Slack channel ID",
16
+ slackConnecting: "Connecting to Slack...",
17
+ slackDisconnecting: "Disconnecting...",
18
+ slackConnect: "Connect to Slack",
19
+ slackConnected: "Connected",
20
+ slackConnectContainerDescription: "Connect to get notifications in your Slack workspace.",
21
+ slackSearchbarDisconnected: "Slack is not connected.",
22
+ slackSearchbarMultipleChannels: "Multiple channels connected",
23
+ slackSearchbarNoChannelsConnected: "Search channels",
24
+ slackSearchbarNoChannelsFound: "No slack channels.",
25
+ slackSearchbarChannelsError: "Error fetching channels.",
26
+ slackSearchChannels: "Search channels",
27
+ slackConnectionErrorExists: "Try reconnecting to Slack to find and select channels from your workspace.",
28
+ slackConnectionErrorOccurred: "There was an error connecting to Slack. Try reconnecting to find and select channels from your workspace.",
29
+ slackChannelAlreadyConnected: "Error: already connected",
30
+ slackError: "Error",
31
+ slackDisconnect: "Disconnect",
32
+ slackChannelSetError: "Error setting channel.",
33
+ slackAccessTokenNotSet: "Access token not set.",
34
+ slackReconnect: "Reconnect",
35
+ },
36
+ locale: "en",
37
+ };
38
+
39
+ export default en;
@@ -0,0 +1,43 @@
1
+ import de from "./de";
2
+ import en from "./en";
3
+
4
+ export interface Translations {
5
+ readonly emptyFeedTitle: string;
6
+ readonly emptyFeedBody: string;
7
+ readonly notifications: string;
8
+ readonly poweredBy: string;
9
+ readonly markAllAsRead: string;
10
+ readonly archiveNotification: string;
11
+ readonly all: string;
12
+ readonly unread: string;
13
+ readonly read: string;
14
+ readonly unseen: string;
15
+ readonly slackConnectChannel: string;
16
+ readonly slackChannelId: string;
17
+ readonly slackConnecting: string;
18
+ readonly slackDisconnecting: string;
19
+ readonly slackConnect: string;
20
+ readonly slackConnected: string;
21
+ readonly slackConnectContainerDescription: string;
22
+ readonly slackSearchbarDisconnected: string;
23
+ readonly slackSearchbarMultipleChannels: string;
24
+ readonly slackSearchbarNoChannelsConnected: string;
25
+ readonly slackSearchbarNoChannelsFound: string;
26
+ readonly slackSearchbarChannelsError: string;
27
+ readonly slackSearchChannels: string;
28
+ readonly slackConnectionErrorOccurred: string;
29
+ readonly slackConnectionErrorExists: string;
30
+ readonly slackChannelAlreadyConnected: string;
31
+ readonly slackError: string;
32
+ readonly slackDisconnect: string;
33
+ readonly slackChannelSetError: string;
34
+ readonly slackAccessTokenNotSet: string;
35
+ readonly slackReconnect: string;
36
+ }
37
+
38
+ export interface I18nContent {
39
+ readonly translations: Partial<Translations>;
40
+ readonly locale: string;
41
+ }
42
+
43
+ export const locales = { en, de };
@@ -0,0 +1,12 @@
1
+ export type ContainerObject = {
2
+ objectId: string;
3
+ collection: string;
4
+ };
5
+
6
+ export type SlackChannelQueryOptions = {
7
+ maxCount?: number;
8
+ limitPerPage?: number;
9
+ excludeArchived?: boolean;
10
+ types?: string;
11
+ teamId?: string;
12
+ };
@@ -0,0 +1,81 @@
1
+ import { useSlackConnectionStatus } from "..";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import * as React from "react";
4
+
5
+ import { slackProviderKey } from "../../core";
6
+ import { useKnockClient } from "../../core";
7
+ import { ConnectionStatus } from "../hooks/useSlackConnectionStatus";
8
+
9
+ const queryClient = new QueryClient();
10
+
11
+ export interface KnockSlackProviderState {
12
+ knockSlackChannelId: string;
13
+ tenant: string;
14
+ connectionStatus: ConnectionStatus;
15
+ setConnectionStatus: (connectionStatus: ConnectionStatus) => void;
16
+ errorLabel: string | null;
17
+ setErrorLabel: (label: string) => void;
18
+ actionLabel: string | null;
19
+ setActionLabel: (label: string | null) => void;
20
+ }
21
+
22
+ const SlackProviderStateContext =
23
+ React.createContext<KnockSlackProviderState | null>(null);
24
+
25
+ export interface KnockSlackProviderProps {
26
+ knockSlackChannelId: string;
27
+ tenant: string;
28
+ children?: React.ReactElement;
29
+ }
30
+
31
+ export const KnockSlackProvider: React.FC<KnockSlackProviderProps> = ({
32
+ knockSlackChannelId,
33
+ tenant,
34
+ children,
35
+ }) => {
36
+ const knock = useKnockClient();
37
+
38
+ const {
39
+ connectionStatus,
40
+ setConnectionStatus,
41
+ errorLabel,
42
+ setErrorLabel,
43
+ actionLabel,
44
+ setActionLabel,
45
+ } = useSlackConnectionStatus(knock, knockSlackChannelId, tenant);
46
+
47
+ return (
48
+ <SlackProviderStateContext.Provider
49
+ key={slackProviderKey({
50
+ knockSlackChannelId,
51
+ tenant,
52
+ connectionStatus,
53
+ errorLabel,
54
+ })}
55
+ value={{
56
+ connectionStatus,
57
+ setConnectionStatus,
58
+ errorLabel,
59
+ setErrorLabel,
60
+ actionLabel,
61
+ setActionLabel,
62
+ knockSlackChannelId,
63
+ tenant,
64
+ }}
65
+ >
66
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
67
+ </SlackProviderStateContext.Provider>
68
+ );
69
+ };
70
+
71
+ export const useKnockSlackClient = (): KnockSlackProviderState => {
72
+ const context = React.useContext(
73
+ SlackProviderStateContext,
74
+ ) as KnockSlackProviderState;
75
+ if (context === undefined) {
76
+ throw new Error(
77
+ "useKnockSlackClient must be used within a KnockSlackProvider",
78
+ );
79
+ }
80
+ return context as KnockSlackProviderState;
81
+ };
@@ -0,0 +1 @@
1
+ export * from "./KnockSlackProvider";
@@ -0,0 +1,4 @@
1
+ export { default as useSlackConnectionStatus } from "./useSlackConnectionStatus";
2
+ export { default as useSlackChannels } from "./useSlackChannels";
3
+ export { default as useConnectedSlackChannels } from "./useConnectedSlackChannels";
4
+ export { default as useSlackAuth } from "./useSlackAuth";
@@ -0,0 +1,105 @@
1
+ import { ContainerObject, useKnockSlackClient } from "..";
2
+ import { SlackChannelConnection } from "@knocklabs/client";
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import { useKnockClient } from "../../core";
6
+ import { useTranslations } from "../../i18n";
7
+
8
+ type UseSlackChannelsProps = {
9
+ slackChannelsRecipientObject: ContainerObject;
10
+ };
11
+
12
+ type UseSlackChannelOutput = {
13
+ data: SlackChannelConnection[] | null;
14
+ updateConnectedChannels: (
15
+ connectedChannels: SlackChannelConnection[],
16
+ ) => void;
17
+ loading: boolean;
18
+ error: string | null;
19
+ updating: boolean;
20
+ };
21
+
22
+ function useConnectedSlackChannels({
23
+ slackChannelsRecipientObject: { objectId, collection },
24
+ }: UseSlackChannelsProps): UseSlackChannelOutput {
25
+ const { t } = useTranslations();
26
+ const knock = useKnockClient();
27
+ const { connectionStatus, knockSlackChannelId } = useKnockSlackClient();
28
+ const [connectedChannels, setConnectedChannels] = useState<
29
+ null | SlackChannelConnection[]
30
+ >(null);
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [isLoading, setIsLoading] = useState(false);
33
+ const [isUpdating, setIsUpdating] = useState(false);
34
+
35
+ const fetchAndSetConnectedChannels = useCallback(() => {
36
+ setIsLoading(true);
37
+ const getConnectedChannels = async () =>
38
+ await knock.objects.getChannelData({
39
+ collection,
40
+ objectId,
41
+ channelId: knockSlackChannelId,
42
+ });
43
+
44
+ getConnectedChannels()
45
+ .then((res) => {
46
+ if (res?.data?.connections) {
47
+ setConnectedChannels(res?.data?.connections);
48
+ } else {
49
+ setConnectedChannels([]);
50
+ }
51
+ setError(null);
52
+ setIsLoading(false);
53
+ })
54
+ .catch(() => {
55
+ setConnectedChannels([]);
56
+ setError(null);
57
+ setIsLoading(false);
58
+ });
59
+ }, [collection, knock.objects, knockSlackChannelId, objectId]);
60
+
61
+ useEffect(() => {
62
+ if (
63
+ connectionStatus === "connected" &&
64
+ !connectedChannels &&
65
+ !error &&
66
+ !isLoading
67
+ ) {
68
+ fetchAndSetConnectedChannels();
69
+ }
70
+ }, [
71
+ connectedChannels,
72
+ fetchAndSetConnectedChannels,
73
+ isLoading,
74
+ error,
75
+ connectionStatus,
76
+ ]);
77
+
78
+ const updateConnectedChannels = async (
79
+ channelsToSendToKnock: SlackChannelConnection[],
80
+ ) => {
81
+ setIsUpdating(true);
82
+ try {
83
+ await knock.objects.setChannelData({
84
+ objectId,
85
+ collection,
86
+ channelId: knockSlackChannelId,
87
+ data: { connections: channelsToSendToKnock },
88
+ });
89
+ fetchAndSetConnectedChannels();
90
+ } catch (error) {
91
+ setError(t("slackChannelSetError") || "");
92
+ }
93
+ setIsUpdating(false);
94
+ };
95
+
96
+ return {
97
+ data: connectedChannels,
98
+ updateConnectedChannels,
99
+ updating: isUpdating,
100
+ loading: isLoading,
101
+ error,
102
+ };
103
+ }
104
+
105
+ export default useConnectedSlackChannels;
@@ -0,0 +1,84 @@
1
+ import { useKnockSlackClient } from "..";
2
+ import { TENANT_OBJECT_COLLECTION } from "@knocklabs/client";
3
+ import { useCallback } from "react";
4
+
5
+ import { useKnockClient } from "../../core";
6
+
7
+ const SLACK_AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize";
8
+ const DEFAULT_SLACK_SCOPES = [
9
+ "chat:write",
10
+ "chat:write.public",
11
+ "channels:read",
12
+ "groups:read"
13
+ ];
14
+
15
+ type UseSlackAuthOutput = {
16
+ buildSlackAuthUrl: () => string;
17
+ disconnectFromSlack: () => void;
18
+ };
19
+
20
+ function useSlackAuth(
21
+ slackClientId: string,
22
+ redirectUrl?: string,
23
+ ): UseSlackAuthOutput {
24
+ const knock = useKnockClient();
25
+ const { setConnectionStatus, knockSlackChannelId, tenant, setActionLabel } =
26
+ useKnockSlackClient();
27
+
28
+ const disconnectFromSlack = useCallback(async () => {
29
+ setActionLabel(null);
30
+ setConnectionStatus("disconnecting");
31
+ try {
32
+ const revoke = await knock.slack.revokeAccessToken({
33
+ tenant,
34
+ knockChannelId: knockSlackChannelId,
35
+ });
36
+
37
+ if (revoke === "ok") {
38
+ setConnectionStatus("disconnected");
39
+ } else {
40
+ setConnectionStatus("error");
41
+ }
42
+ } catch (error) {
43
+ setConnectionStatus("error");
44
+ }
45
+ }, [
46
+ setConnectionStatus,
47
+ knock.slack,
48
+ tenant,
49
+ knockSlackChannelId,
50
+ setActionLabel,
51
+ ]);
52
+
53
+ const buildSlackAuthUrl = useCallback(() => {
54
+ const rawParams = {
55
+ state: JSON.stringify({
56
+ redirect_url: redirectUrl,
57
+ access_token_object: {
58
+ object_id: tenant,
59
+ collection: TENANT_OBJECT_COLLECTION,
60
+ },
61
+ channel_id: knockSlackChannelId,
62
+ public_key: knock.apiKey,
63
+ user_token: knock.userToken,
64
+ }),
65
+ client_id: slackClientId,
66
+ scope: DEFAULT_SLACK_SCOPES.join(","),
67
+ };
68
+ return `${SLACK_AUTHORIZE_URL}?${new URLSearchParams(rawParams)}`;
69
+ }, [
70
+ redirectUrl,
71
+ tenant,
72
+ knockSlackChannelId,
73
+ knock.apiKey,
74
+ knock.userToken,
75
+ slackClientId,
76
+ ]);
77
+
78
+ return {
79
+ buildSlackAuthUrl,
80
+ disconnectFromSlack,
81
+ };
82
+ }
83
+
84
+ export default useSlackAuth;
@@ -0,0 +1,91 @@
1
+ import { SlackChannelQueryOptions, useKnockSlackClient } from "..";
2
+ import { SlackChannel } from "@knocklabs/client";
3
+ import { useInfiniteQuery } from "@tanstack/react-query";
4
+ import { useEffect, useMemo } from "react";
5
+
6
+ import { useKnockClient } from "../../core";
7
+
8
+ const MAX_COUNT = 1000;
9
+ const LIMIT_PER_PAGE = 200;
10
+ const CHANNEL_TYPES = "private_channel,public_channel";
11
+
12
+ type UseSlackChannelsProps = {
13
+ queryOptions?: SlackChannelQueryOptions;
14
+ };
15
+
16
+ type UseSlackChannelOutput = {
17
+ data: SlackChannel[];
18
+ isLoading: boolean;
19
+ refetch: () => void;
20
+ };
21
+
22
+ function useSlackChannels({
23
+ queryOptions,
24
+ }: UseSlackChannelsProps): UseSlackChannelOutput {
25
+ const knock = useKnockClient();
26
+ const { knockSlackChannelId, tenant, connectionStatus } =
27
+ useKnockSlackClient();
28
+
29
+ const fetchChannels = ({ pageParam }: { pageParam: string }) => {
30
+ return knock.slack.getChannels({
31
+ tenant,
32
+ knockChannelId: knockSlackChannelId,
33
+ queryOptions: {
34
+ ...queryOptions,
35
+ cursor: pageParam,
36
+ limit: queryOptions?.limitPerPage || LIMIT_PER_PAGE,
37
+ types: queryOptions?.types || CHANNEL_TYPES,
38
+ },
39
+ });
40
+ };
41
+
42
+ const {
43
+ data,
44
+ isLoading,
45
+ isFetching,
46
+ fetchNextPage,
47
+ hasNextPage,
48
+ refetch,
49
+ error,
50
+ } = useInfiniteQuery({
51
+ queryKey: ["slackChannels"],
52
+ queryFn: fetchChannels,
53
+ initialPageParam: "",
54
+ getNextPageParam: (lastPage) =>
55
+ lastPage?.next_cursor === "" ? null : lastPage?.next_cursor,
56
+ });
57
+
58
+ const slackChannels = useMemo(() => {
59
+ return (
60
+ data?.pages
61
+ ?.flatMap((page) => page?.slack_channels)
62
+ .filter((channel) => !!channel) || []
63
+ );
64
+ }, [data?.pages]);
65
+
66
+ const maxCount = queryOptions?.maxCount || MAX_COUNT;
67
+
68
+ useEffect(() => {
69
+ if (
70
+ connectionStatus === "connected" &&
71
+ !error &&
72
+ hasNextPage &&
73
+ !isFetching &&
74
+ slackChannels?.length < maxCount
75
+ ) {
76
+ fetchNextPage();
77
+ }
78
+ }, [
79
+ slackChannels?.length,
80
+ fetchNextPage,
81
+ hasNextPage,
82
+ isFetching,
83
+ maxCount,
84
+ error,
85
+ connectionStatus,
86
+ ]);
87
+
88
+ return { data: slackChannels, isLoading, refetch };
89
+ }
90
+
91
+ export default useSlackChannels;
@@ -0,0 +1,102 @@
1
+ import Knock from "@knocklabs/client";
2
+ import { useEffect, useState } from "react";
3
+
4
+ import { useTranslations } from "../../i18n";
5
+
6
+ export type ConnectionStatus =
7
+ | "connecting"
8
+ | "connected"
9
+ | "disconnected"
10
+ | "error"
11
+ | "disconnecting";
12
+
13
+ type UseSlackConnectionStatusOutput = {
14
+ connectionStatus: ConnectionStatus;
15
+ setConnectionStatus: (status: ConnectionStatus) => void;
16
+ errorLabel: string | null;
17
+ setErrorLabel: (errorLabel: string) => void;
18
+ actionLabel: string | null;
19
+ setActionLabel: (actionLabel: string | null) => void;
20
+ };
21
+
22
+ /**
23
+ * Transforms a slack error message into
24
+ * a formatted one. Slack error messages: https://api.slack.com/methods/auth.test#errors
25
+ *
26
+ * Ex.: "account_inactive" -> "Account inactive"
27
+ */
28
+ const formatSlackErrorMessage = (errorMessage: string) => {
29
+ const firstLetter = errorMessage.substring(0, 1).toUpperCase();
30
+ const rest = errorMessage.substring(1);
31
+ return firstLetter?.concat(rest).replace("_", " ");
32
+ };
33
+
34
+ function useSlackConnectionStatus(
35
+ knock: Knock,
36
+ knockSlackChannelId: string,
37
+ tenant: string,
38
+ ): UseSlackConnectionStatusOutput {
39
+ const { t } = useTranslations();
40
+ const [connectionStatus, setConnectionStatus] =
41
+ useState<ConnectionStatus>("connecting");
42
+ const [errorLabel, setErrorLabel] = useState<string | null>(null);
43
+ const [actionLabel, setActionLabel] = useState<string | null>(null);
44
+
45
+ useEffect(() => {
46
+ const checkAuthStatus = async () => {
47
+ if (connectionStatus !== "connecting") return;
48
+
49
+ try {
50
+ const authRes = await knock.slack.authCheck({
51
+ tenant,
52
+ knockChannelId: knockSlackChannelId,
53
+ });
54
+
55
+ if (authRes.connection?.ok) {
56
+ return setConnectionStatus("connected");
57
+ }
58
+
59
+ if (!authRes.connection?.ok) {
60
+ return setConnectionStatus("disconnected");
61
+ }
62
+
63
+ // This is a normal response for a tenant that doesn't have an access
64
+ // token set on it, meaning it's not connected to Slack, so we
65
+ // give it a "disconnected" status instead of an error status.
66
+ if (
67
+ authRes.code === "ERR_BAD_REQUEST" &&
68
+ authRes.response?.data?.message === t("slackAccessTokenNotSet")
69
+ ) {
70
+ return setConnectionStatus("disconnected");
71
+ }
72
+
73
+ // This is for an error coming directly from Slack.
74
+ if (!authRes.connection?.ok && authRes.connection?.error) {
75
+ const errorLabel = formatSlackErrorMessage(authRes.connection?.error);
76
+ setErrorLabel(errorLabel);
77
+ setConnectionStatus("error");
78
+ return;
79
+ }
80
+
81
+ // This is for any Knock errors that would require a reconnect.
82
+
83
+ setConnectionStatus("error");
84
+ } catch (error) {
85
+ setConnectionStatus("error");
86
+ }
87
+ };
88
+
89
+ checkAuthStatus();
90
+ }, [connectionStatus, tenant, knockSlackChannelId, knock.slack, t]);
91
+
92
+ return {
93
+ connectionStatus,
94
+ setConnectionStatus,
95
+ errorLabel,
96
+ setErrorLabel,
97
+ actionLabel,
98
+ setActionLabel,
99
+ };
100
+ }
101
+
102
+ export default useSlackConnectionStatus;
@@ -0,0 +1,3 @@
1
+ export * from "./context";
2
+ export * from "./hooks";
3
+ export * from "./constants"