@knocklabs/react-core 0.1.0

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.
package/.eslintrc.js ADDED
@@ -0,0 +1,9 @@
1
+ /** @type {import("eslint").Linter.Config} */
2
+ module.exports = {
3
+ root: true,
4
+ extends: ["@knocklabs/eslint-config/react-internal.js"],
5
+ parser: "@typescript-eslint/parser",
6
+ parserOptions: {
7
+ project: true,
8
+ },
9
+ };
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - bcdbc86: Initialize monorepo
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @knocklabs/react-core
2
+
3
+ A set of shared components, hooks, and utilities for working with React and React Native Knock packages.
4
+
5
+ Not meant to be consumed externally.
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@knocklabs/react-core",
3
+ "description": "A set of React components to build notification experiences powered by Knock",
4
+ "author": "@knocklabs",
5
+ "version": "0.1.0",
6
+ "license": "MIT",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "dependencies": {}
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./modules/core";
2
+ export * from "./modules/feed";
3
+ 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,59 @@
1
+ import * as React from "react";
2
+ import Knock from "@knocklabs/client";
3
+
4
+ import { useAuthenticatedKnockClient } from "../hooks";
5
+ import { KnockI18nProvider, I18nContent } from "../../i18n";
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
+
23
+ // Extra options
24
+ children?: React.ReactElement;
25
+
26
+ // i18n translations
27
+ i18n?: I18nContent;
28
+ }
29
+
30
+ export const KnockProvider: React.FC<KnockProviderProps> = ({
31
+ apiKey,
32
+ host,
33
+ userId,
34
+ userToken,
35
+ children,
36
+ i18n,
37
+ }) => {
38
+ const knock = useAuthenticatedKnockClient(apiKey, userId, userToken, {
39
+ host,
40
+ });
41
+
42
+ return (
43
+ <ProviderStateContext.Provider
44
+ value={{
45
+ knock,
46
+ }}
47
+ >
48
+ <KnockI18nProvider i18n={i18n}>{children}</KnockI18nProvider>
49
+ </ProviderStateContext.Provider>
50
+ );
51
+ };
52
+
53
+ export const useKnockClient = (): Knock => {
54
+ const context = React.useContext(ProviderStateContext) as KnockProviderState;
55
+ if (context === undefined) {
56
+ throw new Error("useKnock must be used within a KnockProvider");
57
+ }
58
+ return context.knock;
59
+ };
@@ -0,0 +1 @@
1
+ export * from "./KnockProvider";
@@ -0,0 +1 @@
1
+ export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient";
@@ -0,0 +1,23 @@
1
+ import React, { useMemo } from "react";
2
+ import Knock, { KnockOptions } from "@knocklabs/client";
3
+
4
+ function useAuthenticatedKnockClient(
5
+ apiKey: string,
6
+ userId: string,
7
+ userToken: string | undefined,
8
+ options: KnockOptions = {},
9
+ ) {
10
+ const knockRef = React.useRef<Knock | null>();
11
+
12
+ return useMemo(() => {
13
+ if (knockRef.current) knockRef.current.teardown();
14
+
15
+ const knock = new Knock(apiKey, options);
16
+ knock.authenticate(userId, userToken);
17
+ knockRef.current = knock;
18
+
19
+ return knock;
20
+ }, [apiKey, userId, userToken]);
21
+ }
22
+
23
+ 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,55 @@
1
+ import { FeedClientOptions } from "@knocklabs/client";
2
+ import { parseISO, formatDistance, Locale } 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?: Locale;
11
+ };
12
+
13
+ export function formatTimestamp(
14
+ ts: string,
15
+ options: FormatTimestampOptions = {},
16
+ ) {
17
+ try {
18
+ const parsedTs = parseISO(ts);
19
+ const formatted = formatDistance(parsedTs, new Date(), {
20
+ addSuffix: true,
21
+ locale: options.locale,
22
+ });
23
+
24
+ return formatted;
25
+ } catch (e) {
26
+ return ts;
27
+ }
28
+ }
29
+
30
+ export function toSentenceCase(string: string): string {
31
+ return string.charAt(0).toUpperCase() + string.slice(1);
32
+ }
33
+
34
+ export function renderNodeOrFallback(node: ReactNode, fallback: ReactNode) {
35
+ return node !== undefined ? node : fallback;
36
+ }
37
+
38
+ /*
39
+ Used to build a consistent key for the KnockFeedProvider so that React knows when
40
+ to trigger a re-render of the context when a key property changes.
41
+ */
42
+ export function feedProviderKey(
43
+ userFeedId: string,
44
+ options: FeedClientOptions = {},
45
+ ) {
46
+ return [
47
+ userFeedId,
48
+ options.source,
49
+ options.tenant,
50
+ options.has_tenant,
51
+ options.archived,
52
+ ]
53
+ .filter((f) => f !== null && f !== undefined)
54
+ .join("-");
55
+ }
@@ -0,0 +1,8 @@
1
+ import { PropsWithChildren } from "react";
2
+ import "./styles.css";
3
+
4
+ export const KnockFeedContainer: React.FC<PropsWithChildren> = ({
5
+ children,
6
+ }) => {
7
+ return <div className="rnf-feed-provider">{children}</div>;
8
+ };
@@ -0,0 +1,78 @@
1
+ import * as React from "react";
2
+ import Knock, {
3
+ Feed,
4
+ FeedClientOptions,
5
+ FeedStoreState,
6
+ } from "@knocklabs/client";
7
+ import create, { StoreApi, UseStore } from "zustand";
8
+
9
+ import { ColorMode } from "../../core/constants";
10
+ import useNotifications from "../hooks/useNotifications";
11
+ import { feedProviderKey } from "../../core/utils";
12
+ import { KnockFeedContainer } from "./KnockFeedContainer";
13
+ import { useKnockClient } from "../../core";
14
+
15
+ export interface KnockFeedProviderState {
16
+ knock: Knock;
17
+ feedClient: Feed;
18
+ useFeedStore: UseStore<FeedStoreState>;
19
+ colorMode: ColorMode;
20
+ }
21
+
22
+ const FeedStateContext = React.createContext<KnockFeedProviderState | null>(
23
+ null,
24
+ );
25
+
26
+ export interface KnockFeedProviderProps {
27
+ // Feed props
28
+ feedId: string;
29
+
30
+ // Extra options
31
+ children?: React.ReactElement;
32
+ rootless?: boolean;
33
+ colorMode?: ColorMode;
34
+
35
+ // Feed client options
36
+ defaultFeedOptions?: FeedClientOptions;
37
+ }
38
+
39
+ export const KnockFeedProvider: React.FC<KnockFeedProviderProps> = ({
40
+ feedId,
41
+ children,
42
+ defaultFeedOptions = {},
43
+ rootless = false,
44
+ colorMode = "light",
45
+ }) => {
46
+ const knock = useKnockClient();
47
+
48
+ const feedClient = useNotifications(knock, feedId, defaultFeedOptions);
49
+ const useFeedStore = create(feedClient.store as StoreApi<FeedStoreState>);
50
+
51
+ const content = rootless ? (
52
+ children
53
+ ) : (
54
+ <KnockFeedContainer>{children}</KnockFeedContainer>
55
+ );
56
+
57
+ return (
58
+ <FeedStateContext.Provider
59
+ key={feedProviderKey(feedId, defaultFeedOptions)}
60
+ value={{
61
+ knock,
62
+ feedClient,
63
+ useFeedStore,
64
+ colorMode,
65
+ }}
66
+ >
67
+ {content}
68
+ </FeedStateContext.Provider>
69
+ );
70
+ };
71
+
72
+ export const useKnockFeed = (): KnockFeedProviderState => {
73
+ const context = React.useContext(FeedStateContext);
74
+ if (context === undefined) {
75
+ throw new Error("useKnockFeed must be used within a KnockFeedProvider");
76
+ }
77
+ return context as KnockFeedProviderState;
78
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./KnockFeedProvider";
2
+ export * from "./KnockFeedContainer";
@@ -0,0 +1,10 @@
1
+ .rnf-feed-provider {
2
+ font-family: var(--rnf-font-family-sanserif) !important;
3
+ margin: 0 !important;
4
+ padding: 0 !important;
5
+ }
6
+
7
+ .rnf-feed-provider * {
8
+ font-family: var(--rnf-font-family-sanserif) !important;
9
+ box-sizing: border-box;
10
+ }
@@ -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,30 @@
1
+ import Knock, { Feed, FeedClientOptions } from "@knocklabs/client";
2
+ import { useMemo, useRef } from "react";
3
+
4
+ function useNotifications(
5
+ knock: Knock,
6
+ feedId: string,
7
+ options: FeedClientOptions = {},
8
+ ) {
9
+ const feedClientRef = useRef<Feed | null>();
10
+
11
+ return useMemo(() => {
12
+ if (feedClientRef.current) feedClientRef.current.teardown();
13
+
14
+ const feedClient = knock.feeds.initialize(feedId, options);
15
+
16
+ feedClient.listenForUpdates();
17
+ feedClientRef.current = feedClient;
18
+
19
+ return feedClient;
20
+ }, [
21
+ knock,
22
+ feedId,
23
+ options.source,
24
+ options.tenant,
25
+ options.has_tenant,
26
+ options.archived,
27
+ ]);
28
+ }
29
+
30
+ 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,22 @@
1
+ import { useContext } from "react";
2
+ import { Locale as DateFnLocale } from "date-fns";
3
+ import * as dateFnsLocales from "date-fns/locale";
4
+ import { I18nContent, locales } from "../languages";
5
+ import { I18nContext } from "../context/KnockI18nProvider";
6
+
7
+ export function useTranslations() {
8
+ const { translations, locale } = useContext<I18nContent>(I18nContext);
9
+
10
+ return {
11
+ locale,
12
+ t: (key: keyof typeof translations) => {
13
+ // We always use english as the default translation when a key doesn't exist
14
+ return translations[key] || locales.en.translations[key];
15
+ },
16
+ dateFnsLocale: (): DateFnLocale => {
17
+ return locale in dateFnsLocales
18
+ ? dateFnsLocales[locale as keyof typeof dateFnsLocales]
19
+ : dateFnsLocales.enUS;
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./context";
2
+ export * from "./hooks";
3
+ export * from "./languages";
@@ -0,0 +1,19 @@
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
+ },
16
+ locale: "de",
17
+ };
18
+
19
+ export default de;
@@ -0,0 +1,18 @@
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
+ },
15
+ locale: "en",
16
+ };
17
+
18
+ export default en;
@@ -0,0 +1,22 @@
1
+ import en from "./en";
2
+ import de from "./de";
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
+ }
16
+
17
+ export interface I18nContent {
18
+ readonly translations: Partial<Translations>;
19
+ readonly locale: string;
20
+ }
21
+
22
+ export const locales = { en, de };
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "@knocklabs/typescript-config/react-library.json",
3
+ "include": ["src"]
4
+ }