├── src ├── components │ ├── Spinner │ │ ├── index.ts │ │ └── Spinner.tsx │ ├── EmptyFeed │ │ ├── index.ts │ │ ├── EmptyFeed.tsx │ │ └── styles.css │ ├── UnseenBadge │ │ ├── index.ts │ │ ├── styles.css │ │ └── UnseenBadge.tsx │ ├── KnockI18nProvider │ │ ├── index.ts │ │ └── KnockI18nProvider.tsx │ ├── NotificationIconButton │ │ ├── index.ts │ │ ├── styles.css │ │ └── NotificationIconButton.tsx │ ├── Button │ │ ├── index.ts │ │ ├── ButtonGroup.tsx │ │ ├── ButtonSpinner.tsx │ │ ├── Button.tsx │ │ └── styles.css │ ├── NotificationFeedPopover │ │ ├── index.ts │ │ ├── styles.css │ │ └── NotificationFeedPopover.tsx │ ├── NotificationCell │ │ ├── index.ts │ │ ├── Avatar.tsx │ │ ├── ArchiveButton.tsx │ │ ├── NotificationCell.tsx │ │ └── styles.css │ ├── KnockFeedProvider │ │ ├── index.ts │ │ ├── KnockFeedContainer.tsx │ │ ├── styles.css │ │ └── KnockFeedProvider.tsx │ ├── NotificationFeed │ │ ├── index.ts │ │ ├── Dropdown.tsx │ │ ├── MarkAsRead.tsx │ │ ├── NotificationFeedHeader.tsx │ │ ├── styles.css │ │ └── NotificationFeed.tsx │ └── Icons │ │ ├── index.ts │ │ ├── ChevronDown.tsx │ │ ├── CheckmarkCircle.tsx │ │ ├── Bell.tsx │ │ └── CloseCircle.tsx ├── constants.ts ├── hooks │ ├── index.ts │ ├── useAuthenticatedKnockClient.ts │ ├── useNotifications.ts │ ├── useTranslations.ts │ ├── useFeedSettings.ts │ ├── useComponentVisible.ts │ └── useOnBottomScroll.ts ├── i18n │ ├── languages │ │ ├── en.ts │ │ └── de.ts │ └── index.ts ├── index.ts ├── utils.ts ├── theme.css └── stories │ └── Feed.stories.tsx ├── utils.d.ts ├── NotificationFeed.png ├── NotificationFeed2.png ├── .storybook ├── preview.js └── main.js ├── .gitignore ├── .github └── workflows │ └── publish.yml ├── tsconfig.json ├── rollup.config.js ├── babel.config.js ├── LICENSE ├── package.json └── README.md /src/components/Spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Spinner"; 2 | -------------------------------------------------------------------------------- /src/components/EmptyFeed/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./EmptyFeed"; 2 | -------------------------------------------------------------------------------- /src/components/UnseenBadge/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UnseenBadge"; 2 | -------------------------------------------------------------------------------- /src/components/KnockI18nProvider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./KnockI18nProvider"; 2 | -------------------------------------------------------------------------------- /src/components/NotificationIconButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NotificationIconButton"; 2 | -------------------------------------------------------------------------------- /utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare function formatBadgeCount(count: number): string | number; 2 | -------------------------------------------------------------------------------- /src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Button"; 2 | export * from "./ButtonGroup"; 3 | -------------------------------------------------------------------------------- /src/components/NotificationFeedPopover/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NotificationFeedPopover"; 2 | -------------------------------------------------------------------------------- /NotificationFeed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knocklabs/react-notification-feed/HEAD/NotificationFeed.png -------------------------------------------------------------------------------- /NotificationFeed2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knocklabs/react-notification-feed/HEAD/NotificationFeed2.png -------------------------------------------------------------------------------- /src/components/NotificationCell/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NotificationCell"; 2 | export * from "./Avatar"; 3 | -------------------------------------------------------------------------------- /src/components/KnockFeedProvider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./KnockFeedProvider"; 2 | export * from "./KnockFeedContainer"; 3 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NotificationFeed"; 2 | export * from "./NotificationFeedHeader"; 3 | export * from "./MarkAsRead"; 4 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum FilterStatus { 2 | All = "all", 3 | Read = "read", 4 | Unseen = "unseen", 5 | Unread = "unread", 6 | } 7 | 8 | export type ColorMode = "light" | "dark"; 9 | -------------------------------------------------------------------------------- /src/components/Button/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./styles.css"; 4 | 5 | export const ButtonGroup: React.FC = ({ children }) => ( 6 |
{children}
7 | ); 8 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/, 8 | }, 9 | }, 10 | } -------------------------------------------------------------------------------- /src/components/Icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BellIcon } from "./Bell"; 2 | export { default as CheckmarkCircle } from "./CheckmarkCircle"; 3 | export { default as ChevronDown } from "./ChevronDown"; 4 | export * from "./CloseCircle"; 5 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient"; 2 | export { default as useOnBottomScroll } from "./useOnBottomScroll"; 3 | export { default as useNotifications } from "./useNotifications"; 4 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 4 | typescript: { 5 | reactDocgen: false, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/KnockFeedProvider/KnockFeedContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./styles.css"; 3 | 4 | export const KnockFeedContainer: React.FC<{ 5 | children: React.ReactNode; 6 | }> = ({ children }) => { 7 | return
{children}
; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/KnockFeedProvider/styles.css: -------------------------------------------------------------------------------- 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 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | /bundle.js 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/components/Button/ButtonSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Spinner } from "../Spinner"; 3 | 4 | import "./styles.css"; 5 | 6 | type ButtonSpinnerProps = { 7 | hasLabel: boolean; 8 | }; 9 | 10 | export const ButtonSpinner: React.FC = ({ hasLabel }) => ( 11 |
16 | 17 |
18 | ); 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '14.x' 13 | registry-url: 'https://registry.npmjs.org' 14 | scope: '@knocklabs' 15 | - run: yarn 16 | - run: yarn publish 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | -------------------------------------------------------------------------------- /src/components/Icons/ChevronDown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ChevronDown = () => ( 4 | 11 | 18 | 19 | ); 20 | 21 | export default ChevronDown; 22 | -------------------------------------------------------------------------------- /src/components/KnockI18nProvider/KnockI18nProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { locales, I18nContent } from "../../i18n"; 3 | 4 | export const I18nContext = React.createContext(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 ; 16 | } 17 | -------------------------------------------------------------------------------- /src/i18n/languages/en.ts: -------------------------------------------------------------------------------- 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 | poweredBy: "Powered by Knock", 15 | }, 16 | locale: "en", 17 | }; 18 | 19 | export default en; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./theme.css"; 2 | export * from "./components/Button"; 3 | export * from "./components/EmptyFeed"; 4 | export * from "./components/Icons"; 5 | export * from "./components/KnockFeedProvider"; 6 | export * from "./components/NotificationCell"; 7 | export * from "./components/NotificationFeed"; 8 | export * from "./components/NotificationFeedPopover"; 9 | export * from "./components/NotificationIconButton"; 10 | export * from "./components/Spinner"; 11 | export * from "./components/UnseenBadge"; 12 | export * from "./constants"; 13 | export * from "./hooks"; 14 | export * as utils from "./utils"; 15 | -------------------------------------------------------------------------------- /src/i18n/languages/de.ts: -------------------------------------------------------------------------------- 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 | poweredBy: "Powered by Knock", 16 | }, 17 | locale: "de", 18 | }; 19 | 20 | export default de; 21 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./languages/en"; 2 | import de from "./languages/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; 19 | readonly locale: string; 20 | } 21 | 22 | export const locales = { en, de }; 23 | -------------------------------------------------------------------------------- /src/components/EmptyFeed/EmptyFeed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTranslations } from "../../hooks/useTranslations"; 3 | import { useKnockFeed } from "../KnockFeedProvider"; 4 | import "./styles.css"; 5 | 6 | export const EmptyFeed: React.FC = () => { 7 | const { colorMode } = useKnockFeed(); 8 | const { t } = useTranslations(); 9 | 10 | return ( 11 |
12 |
13 |

{t("emptyFeedTitle")}

14 |

{t("emptyFeedBody")}

15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/NotificationIconButton/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-notification-icon-button-size: 32px; 3 | --rnf-notification-icon-button-bg-color: transparent; 4 | } 5 | 6 | .rnf-notification-icon-button { 7 | background-color: var(--rnf-notification-icon-button-bg-color); 8 | border: none; 9 | position: relative; 10 | display: block; 11 | margin: 0; 12 | padding: 0; 13 | cursor: pointer; 14 | width: var(--rnf-notification-icon-button-size); 15 | height: var(--rnf-notification-icon-button-size); 16 | color: inherit; 17 | } 18 | 19 | .rnf-notification-icon-button svg { 20 | display: block; 21 | margin: 0 auto; 22 | } 23 | 24 | .rnf-notification-icon-button--dark { 25 | color: #fff; 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useAuthenticatedKnockClient.ts: -------------------------------------------------------------------------------- 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(); 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; 24 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from "react"; 2 | import { ChevronDown } from "../Icons"; 3 | import { useKnockFeed } from "../KnockFeedProvider"; 4 | 5 | import "./styles.css"; 6 | 7 | export type DropdownProps = { 8 | value: string; 9 | onChange: (e: any) => void; 10 | }; 11 | 12 | export const Dropdown: React.FC = ({ 13 | children, 14 | value, 15 | onChange, 16 | }) => { 17 | const { colorMode } = useKnockFeed(); 18 | 19 | return ( 20 |
21 | 24 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useNotifications.ts: -------------------------------------------------------------------------------- 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(); 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 | }, [knock, feedId, options.source, options.tenant, options.has_tenant, options.archived]); 21 | } 22 | 23 | export default useNotifications; 24 | -------------------------------------------------------------------------------- /src/components/NotificationCell/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./styles.css"; 3 | 4 | export default function getInitials(name: string) { 5 | const [firstName, lastName] = name.split(" "); 6 | return firstName && lastName 7 | ? `${firstName.charAt(0)}${lastName.charAt(0)}` 8 | : firstName.charAt(0); 9 | } 10 | 11 | export interface AvatarProps { 12 | name: string; 13 | src?: string | null; 14 | } 15 | 16 | export const Avatar: React.FC = ({ name, src }) => { 17 | return ( 18 |
19 | {src ? ( 20 | {`Image 21 | ) : ( 22 | {getInitials(name)} 23 | )} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/hooks/useTranslations.ts: -------------------------------------------------------------------------------- 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 "../i18n"; 5 | import { I18nContext } from "../components/KnockI18nProvider"; 6 | 7 | export function useTranslations() { 8 | const { translations, locale } = useContext(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] 19 | : dateFnsLocales.enUS; 20 | }, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Icons/CheckmarkCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const CheckmarkCircle = () => ( 4 | 11 | 17 | 24 | 25 | ); 26 | 27 | export default CheckmarkCircle; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "suppressImplicitAnyIndexErrors": true, 13 | "noImplicitAny": true, 14 | "strictFunctionTypes": true, 15 | "strictNullChecks": true, 16 | "strictPropertyInitialization": true, 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "resolveJsonModule": true, 20 | "allowSyntheticDefaultImports": true, 21 | "downlevelIteration": true, 22 | "declarationMap": true, 23 | "declarationDir": "dist/" 24 | }, 25 | "include": ["src/**/*"], 26 | "exclude": ["node_modules", "src/stories/**/*"], 27 | } -------------------------------------------------------------------------------- /src/components/UnseenBadge/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-unseen-badge-bg-color: #eb5757; 3 | --rnf-unseen-badge-size: 16px; 4 | --rnf-unseed-badge-font-size: 9px; 5 | } 6 | 7 | .rnf-unseen-badge { 8 | background-color: var(--rnf-unseen-badge-bg-color); 9 | width: var(--rnf-unseen-badge-size); 10 | height: var(--rnf-unseen-badge-size); 11 | border-radius: var(--rnf-unseen-badge-size); 12 | position: absolute; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | top: 0px; 17 | right: 0px; 18 | } 19 | 20 | .rnf-unseen-badge__count { 21 | font-size: var(--rnf-unseed-badge-font-size); 22 | font-weight: var(--rnf-font-weight-medium); 23 | color: var(--rnf-color-white); 24 | margin-top: -1px; 25 | } 26 | 27 | /* Themes */ 28 | 29 | .rnf-unseen-badge--dark { 30 | --rnf-unseen-badge-bg-color: #ef3434; 31 | } 32 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import json from "@rollup/plugin-json"; 6 | import postcss from "rollup-plugin-postcss"; 7 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 8 | 9 | export default { 10 | input: "src/index.ts", 11 | output: [ 12 | { 13 | file: "./bundle.js", 14 | format: "cjs", 15 | globals: { react: "React" }, 16 | }, 17 | ], 18 | plugins: [ 19 | peerDepsExternal(), 20 | typescript(), 21 | postcss({ extract: "dist/index.css" }), 22 | babel({ exclude: "node_modules/**" }), 23 | resolve(), 24 | commonjs({ include: "node_modules/**" }), 25 | json(), 26 | ], 27 | external: ["react", "react-dom"], 28 | }; 29 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const BABEL_ENV = process.env.BABEL_ENV; 2 | const isCommonJS = BABEL_ENV !== undefined && BABEL_ENV === "cjs"; 3 | const isESM = BABEL_ENV !== undefined && BABEL_ENV === "esm"; 4 | 5 | module.exports = function (api) { 6 | api.cache(true); 7 | 8 | const presets = [ 9 | [ 10 | "@babel/env", 11 | { 12 | loose: false, 13 | modules: isCommonJS ? "commonjs" : false, 14 | targets: { 15 | esmodules: isESM ? true : undefined, 16 | }, 17 | }, 18 | ], 19 | "@babel/preset-typescript", 20 | "@babel/preset-react", 21 | ]; 22 | 23 | let plugins = [ 24 | "@babel/plugin-proposal-class-properties", 25 | "babel-plugin-date-fns", 26 | ]; 27 | 28 | if (BABEL_ENV !== undefined) { 29 | plugins = [ 30 | ...plugins, 31 | [ 32 | "babel-plugin-transform-remove-imports", 33 | { 34 | test: "\\.(less|css)$", 35 | }, 36 | ], 37 | ]; 38 | } 39 | 40 | return { 41 | presets, 42 | plugins, 43 | ignore: ["*.stories.tsx"], 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/NotificationIconButton/NotificationIconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from "react"; 2 | import { BellIcon } from "../Icons"; 3 | import { useKnockFeed } from "../KnockFeedProvider"; 4 | import { BadgeCountType, UnseenBadge } from "../UnseenBadge"; 5 | 6 | import "./styles.css"; 7 | 8 | export interface NotificationIconButtonProps { 9 | // What value should we use to drive the badge count? 10 | badgeCountType?: BadgeCountType; 11 | onClick: (e: SyntheticEvent) => void; 12 | } 13 | 14 | export const NotificationIconButton = React.forwardRef< 15 | HTMLButtonElement, 16 | NotificationIconButtonProps 17 | >(({ onClick, badgeCountType }, ref) => { 18 | const { colorMode } = useKnockFeed(); 19 | 20 | return ( 21 | 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Knock Labs, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/components/EmptyFeed/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-empty-feed-max-w: 240px; 3 | --rnf-empty-feed-header-font-size: var(--rnf-font-size-md); 4 | --rnf-empty-feed-body-font-size: var(--rnf-font-size-sm); 5 | } 6 | 7 | .rnf-empty-feed { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | justify-content: center; 13 | position: relative; 14 | } 15 | 16 | .rnf-empty-feed__inner { 17 | max-width: var(--rnf-empty-feed-max-w); 18 | margin: -20px auto 0; 19 | text-align: center; 20 | } 21 | 22 | .rnf-empty-feed__header { 23 | font-size: var(--rnf-empty-feed-header-font-size); 24 | font-weight: var(--rnf-font-weight-medium); 25 | color: var(--rnf-color-gray-900); 26 | margin: 0 0 var(--rnf-spacing-1); 27 | } 28 | 29 | .rnf-empty-feed__body { 30 | font-size: var(--rnf-empty-feed-body-font-size); 31 | color: var(--rnf-color-gray-300); 32 | margin: 0; 33 | } 34 | 35 | /* Themes */ 36 | 37 | .rnf-empty-feed--dark .rnf-empty-feed__header { 38 | color: var(--rnf-color-white-a-75); 39 | } 40 | 41 | .rnf-empty-feed--dark .rnf-empty-feed__body { 42 | color: var(--rnf-color-gray-400); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/UnseenBadge/UnseenBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useKnockFeed } from "../KnockFeedProvider"; 3 | import { formatBadgeCount } from "../../utils"; 4 | 5 | import "./styles.css"; 6 | import { FeedMetadata } from "@knocklabs/client"; 7 | 8 | export type BadgeCountType = "unseen" | "unread" | "all"; 9 | 10 | export type UnseenBadgeProps = { 11 | badgeCountType?: BadgeCountType; 12 | }; 13 | 14 | function selectBadgeCount( 15 | badgeCountType: BadgeCountType, 16 | metadata: FeedMetadata 17 | ) { 18 | switch (badgeCountType) { 19 | case "all": 20 | return metadata.total_count; 21 | case "unread": 22 | return metadata.unread_count; 23 | case "unseen": 24 | return metadata.unseen_count; 25 | } 26 | } 27 | 28 | export const UnseenBadge: React.FC = ({ 29 | badgeCountType = "unseen", 30 | }) => { 31 | const { useFeedStore } = useKnockFeed(); 32 | const badgeCountValue = useFeedStore((state) => 33 | selectBadgeCount(badgeCountType, state.metadata) 34 | ); 35 | 36 | return badgeCountValue !== 0 ? ( 37 |
38 | 39 | {formatBadgeCount(badgeCountValue)} 40 | 41 |
42 | ) : null; 43 | }; 44 | -------------------------------------------------------------------------------- /src/hooks/useFeedSettings.ts: -------------------------------------------------------------------------------- 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( 11 | feedClient: Feed 12 | ): { settings: FeedSettings | null; loading: boolean } { 13 | const [settings, setSettings] = useState(null); 14 | const [isLoading, setIsLoading] = useState(false); 15 | 16 | // TODO: consider moving this into the feed client and into the feed store state when 17 | // we're using this in other areas of the feed 18 | useEffect(() => { 19 | async function getSettings() { 20 | const knock = feedClient.knock; 21 | const apiClient = knock.client(); 22 | const feedSettingsPath = `/v1/users/${knock.userId}/feeds/${feedClient.feedId}/settings`; 23 | setIsLoading(true); 24 | 25 | const response = await apiClient.makeRequest({ 26 | method: "GET", 27 | url: feedSettingsPath, 28 | }); 29 | 30 | if (!response.error) { 31 | setSettings(response.body); 32 | } 33 | 34 | setIsLoading(false); 35 | } 36 | 37 | getSettings(); 38 | }, []); 39 | 40 | return { settings, loading: isLoading }; 41 | } 42 | 43 | export default useFeedSettings; 44 | -------------------------------------------------------------------------------- /src/components/Icons/Bell.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Bell({ width = 24, height = 24 }) { 4 | return ( 5 | 6 | 13 | 20 | 21 | ); 22 | } 23 | 24 | export default Bell; 25 | -------------------------------------------------------------------------------- /src/hooks/useComponentVisible.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | function contains(parent: HTMLElement | null, child: HTMLElement) { 4 | if (!parent) return false; 5 | return parent === child || parent.contains(child); 6 | } 7 | 8 | type Options = { 9 | closeOnClickOutside: boolean; 10 | }; 11 | 12 | export default function useComponentVisible( 13 | isVisible: boolean, 14 | onClose: (event: Event) => void, 15 | options: Options 16 | ) { 17 | const ref = useRef(null); 18 | 19 | const handleKeydown = (event: KeyboardEvent) => { 20 | if (event.key === "Escape") { 21 | onClose(event); 22 | } 23 | }; 24 | 25 | const handleClickOutside = (event: Event) => { 26 | if ( 27 | options.closeOnClickOutside && 28 | !contains(ref.current, event.target as HTMLElement) 29 | ) { 30 | event.stopPropagation(); 31 | onClose(event); 32 | } 33 | }; 34 | 35 | useEffect(() => { 36 | if (isVisible) { 37 | document.addEventListener("keydown", handleKeydown, true); 38 | document.addEventListener("click", handleClickOutside, true); 39 | } 40 | 41 | return () => { 42 | document.removeEventListener("keydown", handleKeydown, true); 43 | document.removeEventListener("click", handleClickOutside, true); 44 | }; 45 | }, [isVisible]); 46 | 47 | return { ref }; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/MarkAsRead.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FeedItem } from "@knocklabs/client"; 3 | import { useKnockFeed } from "../KnockFeedProvider"; 4 | import { CheckmarkCircle } from "../Icons"; 5 | import { useTranslations } from "../../hooks/useTranslations"; 6 | 7 | import "./styles.css"; 8 | 9 | export type MarkAsReadProps = { 10 | onClick?: (e: React.MouseEvent, unreadItems: FeedItem[]) => void; 11 | }; 12 | 13 | export const MarkAsRead: React.FC = ({ onClick }) => { 14 | const { useFeedStore, feedClient, colorMode } = useKnockFeed(); 15 | const { t } = useTranslations(); 16 | 17 | const unreadItems = useFeedStore((state) => 18 | state.items.filter((item) => !item.read_at) 19 | ); 20 | 21 | const unreadCount = useFeedStore((state) => state.metadata.unread_count); 22 | 23 | const onClickHandler = React.useCallback( 24 | (e: React.MouseEvent) => { 25 | feedClient.markAllAsRead(); 26 | if (onClick) onClick(e, unreadItems); 27 | }, 28 | [feedClient, unreadItems, onClick] 29 | ); 30 | 31 | return ( 32 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/hooks/useOnBottomScroll.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForwardedRef, 3 | RefObject, 4 | useCallback, 5 | useEffect, 6 | useMemo, 7 | } from "react"; 8 | import debounce from "lodash.debounce"; 9 | 10 | type OnBottomScrollOptions = { 11 | ref: RefObject; 12 | callback: () => void; 13 | offset?: number; 14 | }; 15 | 16 | const noop = () => {}; 17 | 18 | function useOnBottomScroll(options: OnBottomScrollOptions) { 19 | const callback = options.callback ?? noop; 20 | const ref = options.ref; 21 | const offset = options.offset ?? 0; 22 | 23 | const debouncedCallback = useMemo(() => debounce(callback, 200), [callback]); 24 | 25 | const handleOnScroll = useCallback(() => { 26 | if (ref.current) { 27 | const scrollNode = ref.current; 28 | const scrollContainerBottomPosition = Math.round( 29 | scrollNode.scrollTop + scrollNode.clientHeight 30 | ); 31 | const scrollPosition = Math.round(scrollNode.scrollHeight - offset); 32 | 33 | if (scrollPosition <= scrollContainerBottomPosition) { 34 | debouncedCallback(); 35 | } 36 | } 37 | }, [debouncedCallback]); 38 | 39 | useEffect(() => { 40 | if (ref.current) { 41 | ref.current.addEventListener("scroll", handleOnScroll); 42 | } 43 | 44 | return () => { 45 | if (ref.current) { 46 | ref.current.removeEventListener("scroll", handleOnScroll); 47 | } 48 | }; 49 | }, [handleOnScroll]); 50 | } 51 | 52 | export default useOnBottomScroll; 53 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 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 | } 56 | -------------------------------------------------------------------------------- /src/components/Icons/CloseCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const CloseCircle = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export { CloseCircle }; 19 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/NotificationFeedHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { SetStateAction } from "react"; 2 | import { FeedItem } from "@knocklabs/client"; 3 | 4 | import { FilterStatus } from "../../constants"; 5 | import { useTranslations } from "../../hooks/useTranslations"; 6 | import { Dropdown } from "./Dropdown"; 7 | import { MarkAsRead } from "./MarkAsRead"; 8 | 9 | export type NotificationFeedHeaderProps = { 10 | filterStatus: FilterStatus; 11 | setFilterStatus: React.Dispatch>; 12 | onMarkAllAsReadClick?: (e: React.MouseEvent, unreadItems: FeedItem[]) => void; 13 | }; 14 | 15 | const OrderedFilterStatuses = [ 16 | FilterStatus.All, 17 | FilterStatus.Unread, 18 | FilterStatus.Read, 19 | ]; 20 | 21 | export const NotificationFeedHeader: React.FC = ({ 22 | onMarkAllAsReadClick, 23 | filterStatus, 24 | setFilterStatus, 25 | }) => { 26 | const { t } = useTranslations(); 27 | 28 | return ( 29 |
30 |
31 | 32 | {t("notifications")} 33 | 34 | setFilterStatus(e.target.value)} 37 | > 38 | {OrderedFilterStatuses.map((filterStatus) => ( 39 | 42 | ))} 43 | 44 |
45 | 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/NotificationCell/ArchiveButton.tsx: -------------------------------------------------------------------------------- 1 | import { FeedItem } from "@knocklabs/client"; 2 | import React, { MouseEvent, useCallback } from "react"; 3 | import { usePopperTooltip } from "react-popper-tooltip"; 4 | import { useTranslations } from "../../hooks/useTranslations"; 5 | import { CloseCircle } from "../Icons"; 6 | import { useKnockFeed } from "../KnockFeedProvider"; 7 | 8 | export interface ArchiveButtonProps { 9 | item: FeedItem; 10 | } 11 | 12 | const ArchiveButton: React.FC = ({ item }) => { 13 | const { colorMode, feedClient } = useKnockFeed(); 14 | const { t } = useTranslations(); 15 | 16 | const onClick = useCallback( 17 | (e: MouseEvent) => { 18 | e.preventDefault(); 19 | e.stopPropagation(); 20 | 21 | feedClient.markAsArchived(item); 22 | }, 23 | [item] 24 | ); 25 | 26 | const { 27 | getTooltipProps, 28 | setTooltipRef, 29 | setTriggerRef, 30 | visible, 31 | } = usePopperTooltip({ placement: "top-end" }); 32 | 33 | return ( 34 | 54 | ); 55 | }; 56 | 57 | export { ArchiveButton }; 58 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useKnockFeed } from "../KnockFeedProvider"; 3 | import { ButtonSpinner } from "./ButtonSpinner"; 4 | 5 | import "./styles.css"; 6 | 7 | export type ButtonProps = { 8 | variant: "primary" | "secondary"; 9 | loadingText?: string; 10 | isLoading?: boolean; 11 | isDisabled?: boolean; 12 | isFullWidth?: boolean; 13 | onClick: (e: React.MouseEvent) => void; 14 | }; 15 | 16 | export const Button: React.FC = ({ 17 | variant = "primary", 18 | loadingText, 19 | isLoading = false, 20 | isDisabled = false, 21 | isFullWidth = false, 22 | onClick, 23 | children, 24 | }) => { 25 | const { colorMode } = useKnockFeed(); 26 | 27 | const classNames = [ 28 | "rnf-button", 29 | `rnf-button--${variant}`, 30 | isFullWidth ? "rnf-button--full-width" : "", 31 | isLoading ? "rnf-button--is-loading" : "", 32 | `rnf-button--${colorMode}`, 33 | ].join(" "); 34 | 35 | // In this case when there's no loading text, we still want to display the original 36 | // content of the button, but make it hidden. That allows us to keep the button width 37 | // consistent and show the spinner in the middle, meaning no layout shift. 38 | const textToShowWhileLoading = loadingText || ( 39 | {children} 40 | ); 41 | 42 | return ( 43 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Speed = "fast" | "slow" | "medium"; 4 | 5 | function speedSwitch(speed: Speed) { 6 | if (speed === "fast") return 600; 7 | if (speed === "slow") return 900; 8 | return 750; 9 | } 10 | 11 | export interface SpinnerProps { 12 | color?: string; 13 | speed?: Speed; 14 | gap?: number; 15 | thickness?: number; 16 | size?: string; 17 | } 18 | 19 | export const Spinner: React.FC = ({ 20 | color = "rgba(0,0,0,0.4)", 21 | speed = "medium", 22 | gap = 4, 23 | thickness = 4, 24 | size = "1em", 25 | ...props 26 | }) => ( 27 | 37 | Circle loading spinner 38 | Image of a partial circle indicating "loading." 39 | 55 | 66 | 67 | ); 68 | -------------------------------------------------------------------------------- /src/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Font sizes */ 3 | --rnf-font-size-xs: 0.75rem; 4 | --rnf-font-size-sm: 0.875rem; 5 | --rnf-font-size-md: 1rem; 6 | --rnf-font-size-lg: 1.125rem; 7 | --rnf-font-size-xl: 1.266rem; 8 | --rnf-font-size-2xl: 1.5rem; 9 | --rnf-font-size-3xl: 1.75rem; 10 | 11 | /* Spacing */ 12 | --rnf-spacing-0: 0; 13 | --rnf-spacing-1: 4px; 14 | --rnf-spacing-2: 8px; 15 | --rnf-spacing-3: 12px; 16 | --rnf-spacing-4: 16px; 17 | --rnf-spacing-5: 20px; 18 | --rnf-spacing-6: 24px; 19 | --rnf-spacing-7: 32px; 20 | --rnf-spacing-8: 42px; 21 | 22 | /* Font weights */ 23 | --rnf-font-weight-normal: 400; 24 | --rnf-font-weight-medium: 500; 25 | --rnf-font-weight-semibold: 600; 26 | --rnf-font-weight-bold: 700; 27 | 28 | /* Font family */ 29 | --rnf-font-family-sanserif: Inter, -apple-system, BlinkMacSystemFont, 30 | "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif; 31 | 32 | /* Border radius */ 33 | --rnf-border-radius-sm: 2px; 34 | --rnf-border-radius-md: 4px; 35 | --rnf-border-radius-lg: 8px; 36 | 37 | /* Shadows */ 38 | --rnf-shadow-sm: 0px 5px 10px rgba(0, 0, 0, 0.12); 39 | --rnf-shadow-md: 0px 8px 30px rgba(0, 0, 0, 0.24); 40 | 41 | /* Colors */ 42 | --rnf-color-white: #fff; 43 | --rnf-color-white-a-75: rgba(255, 255, 255, 0.75); 44 | --rnf-color-black: #000; 45 | --rnf-color-gray-900: #1a1f36; 46 | --rnf-color-gray-800: #3c4257; 47 | --rnf-color-gray-700: #3c4257; 48 | --rnf-color-gray-600: #515669; 49 | --rnf-color-gray-500: #697386; 50 | --rnf-color-gray-400: #9ea0aa; 51 | --rnf-color-gray-300: #a5acb8; 52 | --rnf-color-gray-200: #dddee1; 53 | --rnf-color-gray-100: #e4e8ee; 54 | --rnf-color-brand-500: #e95744; 55 | --rnf-color-brand-700: #e4321b; 56 | --rnf-color-brand-900: #891e10; 57 | 58 | /* Component specific colors */ 59 | --rnf-unread-badge-bg-color: #dd514c; 60 | --rnf-avatar-bg-color: #ef8476; 61 | --rnf-message-cell-unread-dot-bg-color: #f4ada4; 62 | --rnf-message-cell-hover-bg-color: #f1f6fc; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/NotificationFeedPopover/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-notification-feed-popover-max-w: 400px; 3 | --rnf-notification-feed-popover-min-w: 280px; 4 | --rnf-notification-feed-popover-height: 400px; 5 | --rnf-notification-feed-popover-shadow: drop-shadow( 6 | 0px 5px 15px rgba(0, 0, 0, 0.2) 7 | ); 8 | --rnf-notification-feed-popover-shadow-color: rgba(0, 0, 0, 0.2); 9 | --rnf-notification-feed-popover-bg-color: #fff; 10 | --rnf-notification-feed-popover-z-index: 999; 11 | --rnf-notification-feed-popover-arrow-size: 10px; 12 | --rnf-notification-feed-popover-border-radius: 4px; 13 | } 14 | 15 | .rnf-notification-feed-popover { 16 | width: 100%; 17 | max-width: var(--rnf-notification-feed-popover-max-w); 18 | min-width: var(--rnf-notification-feed-popover-min-w); 19 | height: var(--rnf-notification-feed-popover-height); 20 | z-index: var(--rnf-notification-feed-popover-z-index); 21 | } 22 | 23 | .rnf-notification-feed-popover__inner { 24 | overflow: hidden; 25 | background-color: var(--rnf-notification-feed-popover-bg-color); 26 | border-radius: var(--rnf-notification-feed-popover-border-radius); 27 | filter: var(--rnf-notification-feed-popover-shadow); 28 | height: 100%; 29 | } 30 | 31 | .rnf-notification-feed-popover__arrow { 32 | position: absolute; 33 | width: var(--rnf-notification-feed-popover-arrow-size); 34 | height: var(--rnf-notification-feed-popover-arrow-size); 35 | } 36 | 37 | .rnf-notification-feed-popover__arrow::after { 38 | content: " "; 39 | display: block; 40 | background-color: var(--rnf-notification-feed-popover-bg-color); 41 | box-shadow: -1px -1px 1px var(--rnf-notification-feed-popover-shadow-color); 42 | position: absolute; 43 | top: -5px; 44 | left: 0; 45 | transform: rotate(45deg); 46 | width: var(--rnf-notification-feed-popover-arrow-size); 47 | height: var(--rnf-notification-feed-popover-arrow-size); 48 | } 49 | 50 | /* Theme */ 51 | 52 | .rnf-notification-feed-popover--dark { 53 | --rnf-notification-feed-popover-shadow-color: rgba(0, 0, 0, 0.2); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/NotificationFeedPopover/NotificationFeedPopover.tsx: -------------------------------------------------------------------------------- 1 | import React, { RefObject, useEffect } from "react"; 2 | import { usePopper } from "react-popper"; 3 | import { Placement } from "@popperjs/core"; 4 | import { NotificationFeed, NotificationFeedProps } from "../NotificationFeed"; 5 | import useComponentVisible from "../../hooks/useComponentVisible"; 6 | 7 | import "./styles.css"; 8 | import { useKnockFeed } from "../KnockFeedProvider"; 9 | import { Feed, FeedStoreState } from "@knocklabs/client"; 10 | 11 | type OnOpenOptions = { 12 | store: FeedStoreState; 13 | feedClient: Feed; 14 | }; 15 | 16 | const defaultOnOpen = ({ store, feedClient }: OnOpenOptions) => { 17 | if (store.metadata.unseen_count > 0) { 18 | feedClient.markAllAsSeen(); 19 | } 20 | }; 21 | 22 | export interface NotificationFeedPopoverProps extends NotificationFeedProps { 23 | isVisible: boolean; 24 | onOpen?: (arg: OnOpenOptions) => void; 25 | onClose: (e: Event) => void; 26 | buttonRef: RefObject; 27 | closeOnClickOutside?: boolean; 28 | placement?: Placement; 29 | } 30 | 31 | export const NotificationFeedPopover: React.FC = ({ 32 | isVisible, 33 | onOpen = defaultOnOpen, 34 | onClose, 35 | buttonRef, 36 | closeOnClickOutside = true, 37 | placement = "bottom-end", 38 | ...feedProps 39 | }) => { 40 | const { colorMode, feedClient, useFeedStore } = useKnockFeed(); 41 | const store = useFeedStore(); 42 | 43 | const { ref: popperRef } = useComponentVisible(isVisible, onClose, { 44 | closeOnClickOutside, 45 | }); 46 | 47 | const { styles, attributes } = usePopper( 48 | buttonRef.current, 49 | popperRef.current, 50 | { 51 | strategy: "fixed", 52 | placement, 53 | modifiers: [ 54 | { 55 | name: "offset", 56 | options: { 57 | offset: [0, 8], 58 | }, 59 | }, 60 | ], 61 | } 62 | ); 63 | 64 | useEffect(() => { 65 | // Whenever the feed is opened, we want to invoke the `onOpen` callback 66 | // function to handle any side effects. 67 | if (isVisible && onOpen) { 68 | onOpen({ store, feedClient }); 69 | } 70 | }, [isVisible]); 71 | 72 | return ( 73 |
84 |
85 | 86 |
87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/KnockFeedProvider/KnockFeedProvider.tsx: -------------------------------------------------------------------------------- 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 "../../constants"; 10 | import { useAuthenticatedKnockClient, useNotifications } from "../../hooks"; 11 | import { feedProviderKey } from "../../utils"; 12 | import { KnockFeedContainer } from "./KnockFeedContainer"; 13 | import { KnockI18nProvider } from "../KnockI18nProvider"; 14 | import { I18nContent } from "../../i18n"; 15 | 16 | export interface KnockFeedProviderState { 17 | knock: Knock; 18 | feedClient: Feed; 19 | useFeedStore: UseStore; 20 | colorMode: ColorMode; 21 | } 22 | 23 | const FeedStateContext = React.createContext( 24 | null 25 | ); 26 | 27 | export interface KnockFeedProviderProps { 28 | // Knock client props 29 | apiKey: string; 30 | host?: string; 31 | // Authentication props 32 | userId: string; 33 | userToken?: string; 34 | // Feed props 35 | feedId: string; 36 | 37 | // Extra options 38 | children?: React.ReactElement; 39 | colorMode?: ColorMode; 40 | rootless?: boolean; 41 | 42 | // Feed client options 43 | defaultFeedOptions?: FeedClientOptions; 44 | 45 | // i18n translations 46 | i18n?: I18nContent; 47 | } 48 | 49 | export const KnockFeedProvider: React.FC = ({ 50 | apiKey, 51 | host, 52 | userId, 53 | userToken, 54 | feedId, 55 | children, 56 | defaultFeedOptions = {}, 57 | colorMode = "light", 58 | rootless = false, 59 | i18n, 60 | }) => { 61 | const knock = useAuthenticatedKnockClient(apiKey, userId, userToken, { 62 | host, 63 | }); 64 | 65 | const feedClient = useNotifications(knock, feedId, defaultFeedOptions); 66 | const useFeedStore = create(feedClient.store as StoreApi); 67 | 68 | const content = rootless ? ( 69 | children 70 | ) : ( 71 | {children} 72 | ); 73 | 74 | return ( 75 | 84 | {content} 85 | 86 | ); 87 | }; 88 | 89 | export function useKnockFeed(): KnockFeedProviderState { 90 | const context = React.useContext(FeedStateContext); 91 | if (context === undefined) { 92 | throw new Error("useFeedState must be used within a FeedProvider"); 93 | } 94 | return context as KnockFeedProviderState; 95 | } 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@knocklabs/react-notification-feed", 3 | "version": "0.8.15", 4 | "description": "A set of React components to render feeds powered by Knock", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "types": "dist/types/index.d.ts", 8 | "typings": "dist/types/index.d.ts", 9 | "style": "dist/index.css", 10 | "exports": { 11 | "./dist/index.css": "./dist/index.css", 12 | ".": { 13 | "require": "./dist/cjs/index.js", 14 | "types": "./dist/types/index.d.ts", 15 | "default": "./dist/esm/index.js" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "README.md" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/knocklabs/react-notification-feed.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/knocklabs/react-notification-feed/issues" 28 | }, 29 | "scripts": { 30 | "clean": "rimraf dist", 31 | "type-check": "tsc --noEmit", 32 | "type-check:watch": "npm run type-check -- --watch", 33 | "build": "yarn clean && yarn build:esm && yarn build:cjs && yarn build:types && yarn build:css", 34 | "build:esm": "cross-env BABEL_ENV=esm babel src --extensions .ts,.tsx -d dist/esm --source-maps --ignore 'src/stories/*'", 35 | "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions .ts,.tsx -d dist/cjs --source-maps --ignore 'src/stories/*'", 36 | "build:types": "tsc --emitDeclarationOnly --declaration --declarationDir dist/types", 37 | "build:css": "BABEL_ENV=css rollup -c", 38 | "test": "echo \"Error: no test specified\" && exit 1", 39 | "verify": "npm run build && npm run test", 40 | "storybook": "start-storybook -p 6006", 41 | "build-storybook": "build-storybook", 42 | "prepublishOnly": "npm run build" 43 | }, 44 | "author": "@knocklabs", 45 | "license": "ISC", 46 | "peerDependencies": { 47 | "react": ">=16.8.0", 48 | "react-dom": ">=16.8.0" 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.13.16", 52 | "@babel/core": "^7.14.0", 53 | "@babel/plugin-proposal-class-properties": "^7.13.0", 54 | "@babel/preset-env": "^7.14.1", 55 | "@babel/preset-react": "^7.13.13", 56 | "@babel/preset-typescript": "^7.13.0", 57 | "@babel/runtime": "^7.14.0", 58 | "@rollup/plugin-babel": "^5.3.0", 59 | "@rollup/plugin-commonjs": "^20.0.0", 60 | "@rollup/plugin-json": "^4.1.0", 61 | "@rollup/plugin-node-resolve": "^13.0.4", 62 | "@rollup/plugin-typescript": "^8.2.5", 63 | "@storybook/addon-actions": "^6.2.9", 64 | "@storybook/addon-essentials": "^6.2.9", 65 | "@storybook/addon-links": "^6.2.9", 66 | "@storybook/react": "^6.2.9", 67 | "@types/lodash.debounce": "^4.0.6", 68 | "@types/phoenix": "^1.5.1", 69 | "@types/react": "^17.0.4", 70 | "babel-loader": "^8.2.2", 71 | "babel-plugin-date-fns": "^2.0.0", 72 | "babel-plugin-transform-remove-imports": "^1.5.5", 73 | "cross-env": "^7.0.3", 74 | "postcss": "^8.3.6", 75 | "react": ">=16.8.0", 76 | "react-dom": ">=16.8.0", 77 | "rimraf": "^3.0.2", 78 | "rollup": "^2.56.2", 79 | "rollup-plugin-peer-deps-external": "^2.2.4", 80 | "rollup-plugin-postcss": "^4.0.0", 81 | "typescript": "^4.2.4" 82 | }, 83 | "dependencies": { 84 | "@knocklabs/client": "^0.8.14", 85 | "@popperjs/core": "^2.9.2", 86 | "date-fns": "^2.24.0", 87 | "lodash.debounce": "^4.0.8", 88 | "react-popper": "^2.2.5", 89 | "react-popper-tooltip": "^4.3.0", 90 | "zustand": "^3.5.10" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React notification feed 2 | 3 | **Note: This package has been archived. If you're using `@knocklabs/react-notification-feed`, check out our [migration guide](https://docs.knock.app/in-app-ui/react/migrating-from-react-notification-feed) to learn how to use our [new React library](https://github.com/knocklabs/javascript/tree/main/packages/react).** 4 | 5 | A set of components for integrating [Knock](https://knock.app) in-app notifications into a React application. 6 | 7 | [See a live demo](https://knock-in-app-notifications-react.vercel.app/) 8 | 9 | ![In-app feed component example](NotificationFeed2.png) 10 | 11 | **Note: these components are currently designed to be used in conjunction with the Knock in-app feed 12 | channel, and via React for web only.** 13 | 14 | [Full documentation](https://docs.knock.app/in-app-ui/react/overview) 15 | 16 | ## Installation 17 | 18 | Via NPM: 19 | 20 | ``` 21 | npm install @knocklabs/react-notification-feed 22 | ``` 23 | 24 | Via Yarn: 25 | 26 | ``` 27 | yarn add @knocklabs/react-notification-feed 28 | ``` 29 | 30 | ## Configuration 31 | 32 | To configure the feed you will need: 33 | 34 | 1. A public API key (found in the Knock dashboard) 35 | 2. A feed channel ID (found in the Knock dashboard) 36 | 3. A user ID, and optionally an auth token for production environments 37 | 38 | ## Usage 39 | 40 | You can integrate the feed into your app as follows: 41 | 42 | ```jsx 43 | import { 44 | KnockFeedProvider, 45 | NotificationIconButton, 46 | NotificationFeedPopover, 47 | } from "@knocklabs/react-notification-feed"; 48 | 49 | // Required CSS import, unless you're overriding the styling 50 | import "@knocklabs/react-notification-feed/dist/index.css"; 51 | 52 | const YourAppLayout = () => { 53 | const [isVisible, setIsVisible] = useState(false); 54 | const notifButtonRef = useRef(null); 55 | 56 | return ( 57 | 62 | <> 63 | setIsVisible(!isVisible)} 66 | /> 67 | setIsVisible(false)} 71 | /> 72 | 73 | 74 | ); 75 | }; 76 | ``` 77 | 78 | ## Headless usage 79 | 80 | Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks: 81 | 82 | ```jsx 83 | import { 84 | useAuthenticatedKnockClient, 85 | useNotifications, 86 | } from "@knocklabs/react-notification-feed"; 87 | import create from "zustand"; 88 | 89 | const YourAppLayout = () => { 90 | const knockClient = useAuthenticatedKnockClient( 91 | process.env.KNOCK_PUBLIC_API_KEY, 92 | currentUser.id 93 | ); 94 | 95 | const notificationFeed = useNotifications( 96 | knockClient, 97 | process.env.KNOCK_FEED_ID 98 | ); 99 | 100 | const useNotificationStore = create(notificationFeed.store); 101 | const { metadata } = useNotificationStore(); 102 | 103 | useEffect(() => { 104 | notificationFeed.fetch(); 105 | }, [notificationFeed]); 106 | 107 | return Total unread: {metadata.unread_count}; 108 | }; 109 | ``` 110 | 111 | ## Related links 112 | 113 | - [Signup for Knock](https://knock.app) 114 | - [Knock documentation](https://docs.knock.app) 115 | - [Knock dashboard](https://dashboard.knock.app) 116 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-notification-feed-header-height: 45px; 3 | } 4 | 5 | /* Container */ 6 | .rnf-notification-feed { 7 | background-color: var(--rnf-color-white); 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | 13 | /* Dropdown */ 14 | 15 | .rnf-dropdown { 16 | font-size: var(--rnf-font-size-md); 17 | font-weight: var(--rnf-font-weight-medium); 18 | color: var(--rnf-color-gray-400); 19 | position: relative; 20 | } 21 | 22 | .rnf-dropdown select { 23 | padding-right: var(--rnf-spacing-3); 24 | color: currentColor; 25 | border: none; 26 | background: transparent; 27 | appearance: none; 28 | font-size: var(--rnf-font-size-sm); 29 | position: relative; 30 | text-align: right; 31 | z-index: 2; 32 | } 33 | 34 | .rnf-dropdown svg { 35 | position: absolute; 36 | top: 50%; 37 | margin-top: -2px; 38 | right: 0; 39 | z-index: 1; 40 | } 41 | 42 | /* Mark all as read */ 43 | 44 | .rnf-mark-all-as-read { 45 | border: none; 46 | background: transparent; 47 | margin-left: auto; 48 | display: flex; 49 | align-items: center; 50 | padding: 0; 51 | font-size: var(--rnf-font-size-sm); 52 | color: var(--rnf-color-gray-400); 53 | cursor: pointer; 54 | } 55 | 56 | .rnf-mark-all-as-read:disabled { 57 | color: var(--rnf-color-gray-200); 58 | cursor: not-allowed; 59 | } 60 | 61 | .rnf-mark-all-as-read svg { 62 | margin-top: 1px; 63 | margin-left: var(--rnf-spacing-1); 64 | } 65 | 66 | /* Header */ 67 | 68 | .rnf-notification-feed__header { 69 | padding: var(--rnf-spacing-3) var(--rnf-spacing-4); 70 | height: var(--rnf-notification-feed-header-height); 71 | display: flex; 72 | align-items: center; 73 | } 74 | 75 | .rnf-notification-feed__selector { 76 | display: flex; 77 | align-items: center; 78 | } 79 | 80 | .rnf-notification-feed__type { 81 | font-size: var(--rnf-font-size-sm); 82 | font-weight: var(--rnf-font-weight-medium); 83 | color: var(--rnf-color-gray-900); 84 | margin-right: var(--rnf-spacing-2); 85 | } 86 | 87 | .rnf-notification-feed__container { 88 | overflow-y: auto; 89 | flex: 1; 90 | } 91 | 92 | .rnf-notification-feed__spinner-container { 93 | padding: var(--rnf-spacing-3) var(--rnf-spacing-4); 94 | } 95 | 96 | .rnf-notification-feed__spinner-container svg { 97 | margin: 0 auto; 98 | display: block; 99 | } 100 | 101 | /* Knock branding */ 102 | 103 | .rnf-notification-feed__knock-branding { 104 | text-align: center; 105 | } 106 | 107 | .rnf-notification-feed__knock-branding a { 108 | display: block; 109 | font-size: var(--rnf-font-size-sm); 110 | color: var(--rnf-color-gray-500); 111 | padding: 6px; 112 | border-top: 1px solid var(--rnf-color-gray-100); 113 | } 114 | 115 | .rnf-notification-feed__knock-branding a:hover { 116 | background-color: #f1f6fc; 117 | } 118 | 119 | /* Themes */ 120 | 121 | .rnf-notification-feed--dark { 122 | background-color: #2e2f34; 123 | } 124 | 125 | .rnf-notification-feed--dark .rnf-notification-feed__type { 126 | color: var(--rnf-color-white-a-75); 127 | } 128 | 129 | .rnf-dropdown--dark { 130 | color: var(--rnf-color-gray-400); 131 | } 132 | 133 | .rnf-mark-all-as-read--dark:disabled { 134 | color: var(--rnf-color-gray-500); 135 | } 136 | 137 | .rnf-notification-feed--dark .rnf-notification-feed__knock-branding a { 138 | color: var(--rnf-color-gray-400); 139 | border-top-color: rgba(105, 115, 134, 0.65); 140 | } 141 | 142 | .rnf-notification-feed--dark .rnf-notification-feed__knock-branding a:hover { 143 | background-color: #393b40; 144 | } -------------------------------------------------------------------------------- /src/components/Button/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-button-padding-x: 8px; 3 | --rnf-button-padding-y: 4px; 4 | --rnf-button-border-radius: 4px; 5 | --rnf-button-font-weight: var(--rnf-font-weight-medium); 6 | --rnf-button-font-size: var(--rnf-font-size-sm); 7 | 8 | /* Variant colors */ 9 | /* Primary */ 10 | --rnf-button-primary-bg-color: var(--rnf-color-brand-500); 11 | --rnf-button-primary-hover-bg-color: var(--rnf-color-brand-700); 12 | --rnf-button-primary-border-color: transparent; 13 | --rnf-button-primary-text-color: var(--rnf-color-white); 14 | /* Secondary */ 15 | --rnf-button-secondary-bg-color: var(--rnf-color-white); 16 | --rnf-button-secondary-hover-bg-color: #dddee1; 17 | --rnf-button-secondary-border-color: #dddee1; 18 | --rnf-button-secondary-text-color: var(--rnf-color-gray-700); 19 | } 20 | 21 | .rnf-button { 22 | display: inline-flex; 23 | align-items: center; 24 | justify-content: center; 25 | user-select: none; 26 | white-space: nowrap; 27 | vertical-align: middle; 28 | width: auto; 29 | padding: var(--rnf-button-padding-y) var(--rnf-button-padding-x); 30 | border-radius: var(--rnf-button-border-radius); 31 | font-size: var(--rnf-button-font-size); 32 | line-height: var(--rnf-font-size-lg); 33 | font-weight: var(--rnf-button-font-weight); 34 | border: 1px solid; 35 | appearance: none; 36 | cursor: pointer; 37 | transition: all 0.1s ease-in-out; 38 | } 39 | 40 | .rnf-button--full-width { 41 | width: 100%; 42 | } 43 | 44 | .rnf-button--primary { 45 | background-color: var(--rnf-button-primary-bg-color); 46 | color: var(--rnf-button-primary-text-color); 47 | border-color: var(--rnf-button-primary-border-color); 48 | } 49 | 50 | .rnf-button--primary:hover:not(:disabled), 51 | .rnf-button--primary:active:not(:disabled) { 52 | background-color: var(--rnf-button-primary-hover-bg-color); 53 | } 54 | 55 | .rnf-button:disabled { 56 | opacity: 0.4; 57 | cursor: not-allowed; 58 | } 59 | 60 | .rnf-button--secondary { 61 | background-color: var(--rnf-button-secondary-bg-color); 62 | color: var(--rnf-button-secondary-text-color); 63 | border-color: var(--rnf-button-secondary-border-color); 64 | } 65 | 66 | .rnf-button--secondary:hover:not(:disabled), 67 | .rnf-button--secondary:active:not(:disabled) { 68 | background-color: var(--rnf-button-secondary-hover-bg-color); 69 | } 70 | 71 | .rnf-button--dark.rnf-button--secondary { 72 | border-color: #43464c; 73 | background-color: #43464c; 74 | color: var(--rnf-color-white-a-75); 75 | } 76 | 77 | .rnf-button__button-text-hidden { 78 | opacity: 0; 79 | } 80 | 81 | .rnf-button--dark.rnf-button--secondary:hover:not(:disabled), 82 | .rnf-button--dark.rnf-button--secondary:active:not(:disabled) { 83 | background-color: var(--rnf-color-gray-600); 84 | } 85 | 86 | /* Button spinner */ 87 | 88 | .rnf-button-spinner { 89 | display: flex; 90 | align-items: center; 91 | font-size: 1rem; 92 | line-height: "normal"; 93 | } 94 | 95 | .rnf-button-spinner--without-label { 96 | position: absolute; 97 | } 98 | 99 | .rnf-button-spinner--with-label { 100 | margin-right: 6px; 101 | } 102 | 103 | .rnf-button--primary .rnf-button-spinner circle { 104 | stroke: white; 105 | } 106 | 107 | .rnf-button--secondary .rnf-button-spinner circle { 108 | stroke: var(--rnf-button-secondary-text-color); 109 | } 110 | 111 | .rnf-button--dark.rnf-button--secondary .rnf-button-spinner circle { 112 | stroke: var(--rnf-color-white-a-75); 113 | } 114 | 115 | /* Button group */ 116 | 117 | .rnf-button-group > .rnf-button + .rnf-button { 118 | margin-left: 8px; 119 | } 120 | -------------------------------------------------------------------------------- /src/components/NotificationCell/NotificationCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useMemo } from "react"; 2 | import { ContentBlock, FeedItem } from "@knocklabs/client"; 3 | import { Avatar } from "./Avatar"; 4 | import { ArchiveButton } from "./ArchiveButton"; 5 | import { useKnockFeed } from "../KnockFeedProvider"; 6 | import { formatTimestamp, renderNodeOrFallback } from "../../utils"; 7 | 8 | import "./styles.css"; 9 | import { useTranslations } from "../../hooks/useTranslations"; 10 | 11 | export interface NotificationCellProps { 12 | item: FeedItem; 13 | onItemClick?: (item: FeedItem) => void; 14 | avatar?: ReactNode; 15 | children?: ReactNode; 16 | archiveButton?: ReactNode; 17 | } 18 | 19 | type BlockByName = { 20 | [name: string]: ContentBlock; 21 | }; 22 | 23 | export const NotificationCell = React.forwardRef< 24 | HTMLDivElement, 25 | NotificationCellProps 26 | >(({ item, onItemClick, avatar, children, archiveButton }, ref) => { 27 | const { feedClient, colorMode } = useKnockFeed(); 28 | const { dateFnsLocale } = useTranslations(); 29 | 30 | const blocksByName: BlockByName = useMemo(() => { 31 | return item.blocks.reduce((acc, block) => { 32 | return { ...acc, [block.name]: block }; 33 | }, {}); 34 | }, [item]); 35 | 36 | const actionUrl = blocksByName.action_url && blocksByName.action_url.rendered; 37 | 38 | const onClick = React.useCallback(() => { 39 | // Mark as interacted + read once we click the item 40 | feedClient.markAsInteracted(item); 41 | 42 | if (onItemClick) return onItemClick(item); 43 | 44 | // Delay when we navigate, until we've actually issued our API call. 45 | setTimeout(() => { 46 | if (actionUrl && actionUrl !== "") { 47 | window.location.assign(actionUrl); 48 | } 49 | }, 200); 50 | }, [item]); 51 | 52 | const onKeyDown = React.useCallback( 53 | (ev: React.KeyboardEvent) => { 54 | switch (ev.key) { 55 | case "Enter": { 56 | ev.stopPropagation(); 57 | onClick(); 58 | break; 59 | } 60 | default: 61 | break; 62 | } 63 | }, 64 | [onClick] 65 | ); 66 | 67 | const actor = item.actors[0]; 68 | 69 | return ( 70 |
77 |
78 | {!item.read_at &&
} 79 | 80 | {renderNodeOrFallback( 81 | avatar, 82 | actor && "name" in actor && actor.name && ( 83 | 84 | ) 85 | )} 86 | 87 |
88 | {blocksByName.body && ( 89 |
93 | )} 94 | 95 | {children && ( 96 |
97 | {children} 98 |
99 | )} 100 | 101 | 102 | {formatTimestamp(item.inserted_at, { locale: dateFnsLocale() })} 103 | 104 |
105 | 106 | {renderNodeOrFallback(archiveButton, )} 107 |
108 |
109 | ); 110 | }); 111 | -------------------------------------------------------------------------------- /src/stories/Feed.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | import { 4 | Button, 5 | KnockFeedProvider, 6 | NotificationIconButton, 7 | NotificationFeedPopover, 8 | ButtonGroup, 9 | } from "../"; 10 | 11 | import "../theme.css"; 12 | import { Avatar, NotificationCell } from "../components/NotificationCell"; 13 | 14 | export default { 15 | title: "Feed", 16 | argTypes: { 17 | apiKey: { 18 | name: "API key", 19 | type: { name: "string", required: true }, 20 | defaultValue: "pk_test_3WZFRVstQbNkDEhF_1gfXn3Ka3WDHSZG9FLltmV8-Pc", 21 | }, 22 | userId: { 23 | name: "User ID", 24 | type: { name: "string", required: true }, 25 | defaultValue: "chris", 26 | }, 27 | feedId: { 28 | name: "Feed ID", 29 | type: { name: "string", required: true }, 30 | defaultValue: "2b3fe2a4-4d97-4483-a86c-ede25a137c32", 31 | }, 32 | host: { 33 | name: "Host", 34 | type: { name: "string", required: false }, 35 | defaultValue: "https://api.knock-dev.app", 36 | }, 37 | tenant: { 38 | name: "Tenant ID", 39 | type: { name: "string", required: false }, 40 | defaultValue: "", 41 | }, 42 | }, 43 | } as Meta; 44 | 45 | type Props = { 46 | host?: string; 47 | userId: string; 48 | feedId: string; 49 | apiKey: string; 50 | tenant: string; 51 | }; 52 | 53 | const Template: Story = (args) => { 54 | const buttonRef = useRef(null); 55 | const [isVisible, setIsVisible] = useState(false); 56 | 57 | const colorMode = "dark"; 58 | 59 | return ( 60 | 70 |
78 | setIsVisible(!isVisible)} 81 | /> 82 | setIsVisible(false)} 86 | renderItem={(props) => ( 87 | // Example of overriding the avatar rendering and cell rendering 88 | } 92 | > 93 | {props.item.source.key === "new-comment-1" && ( 94 | 95 | 104 | 113 | 114 | )} 115 | 116 | )} 117 | onNotificationClick={(e) => { 118 | // Handle the notification click 119 | console.log(e); 120 | }} 121 | /> 122 |
123 |
124 | ); 125 | }; 126 | 127 | export const FeedPage = Template.bind({}); 128 | -------------------------------------------------------------------------------- /src/components/NotificationFeed/NotificationFeed.tsx: -------------------------------------------------------------------------------- 1 | import { FeedItem, isRequestInFlight, NetworkStatus } from "@knocklabs/client"; 2 | import React, { 3 | ReactElement, 4 | ReactNode, 5 | useCallback, 6 | useEffect, 7 | useRef, 8 | useState, 9 | } from "react"; 10 | import { EmptyFeed } from "../EmptyFeed"; 11 | import { useKnockFeed } from "../KnockFeedProvider"; 12 | import { Spinner } from "../Spinner"; 13 | import { NotificationCell } from "../NotificationCell"; 14 | import { ColorMode, FilterStatus } from "../../constants"; 15 | import { 16 | NotificationFeedHeader, 17 | NotificationFeedHeaderProps, 18 | } from "./NotificationFeedHeader"; 19 | 20 | import "./styles.css"; 21 | import useOnBottomScroll from "../../hooks/useOnBottomScroll"; 22 | import useFeedSettings from "../../hooks/useFeedSettings"; 23 | import { useTranslations } from "../../hooks/useTranslations"; 24 | import { renderNodeOrFallback } from "../../utils"; 25 | 26 | export type OnNotificationClick = (item: FeedItem) => void; 27 | export type RenderItem = ({ item }: RenderItemProps) => ReactNode; 28 | export type RenderItemProps = { 29 | item: FeedItem; 30 | onItemClick?: OnNotificationClick; 31 | }; 32 | 33 | export interface NotificationFeedProps { 34 | EmptyComponent?: ReactNode; 35 | /** 36 | * @deprecated Use `renderHeader` instead to accept `NotificationFeedHeaderProps` 37 | */ 38 | header?: ReactElement; 39 | renderItem?: RenderItem; 40 | renderHeader?: (props: NotificationFeedHeaderProps) => ReactNode; 41 | onNotificationClick?: OnNotificationClick; 42 | onMarkAllAsReadClick?: (e: React.MouseEvent, unreadItems: FeedItem[]) => void; 43 | initialFilterStatus?: FilterStatus; 44 | } 45 | 46 | const defaultRenderItem = (props: RenderItemProps) => ( 47 | 48 | ); 49 | 50 | const defaultRenderHeader = (props: NotificationFeedHeaderProps) => ( 51 | 52 | ); 53 | 54 | const LoadingSpinner = ({ colorMode }: { colorMode: ColorMode }) => ( 55 |
56 | 61 |
62 | ); 63 | 64 | const poweredByKnockUrl = 65 | "https://knock.app?utm_source=powered-by-knock&utm_medium=referral&utm_campaign=knock-branding-feed"; 66 | 67 | export const NotificationFeed: React.FC = ({ 68 | EmptyComponent = , 69 | renderItem = defaultRenderItem, 70 | onNotificationClick, 71 | onMarkAllAsReadClick, 72 | initialFilterStatus = FilterStatus.All, 73 | header, 74 | renderHeader = defaultRenderHeader, 75 | }) => { 76 | const [status, setStatus] = useState(initialFilterStatus); 77 | const { feedClient, useFeedStore, colorMode } = useKnockFeed(); 78 | const { settings } = useFeedSettings(feedClient); 79 | const { t } = useTranslations(); 80 | 81 | const { pageInfo, items, networkStatus } = useFeedStore(); 82 | const containerRef = useRef(null); 83 | 84 | useEffect(() => { 85 | setStatus(initialFilterStatus); 86 | }, [initialFilterStatus]); 87 | 88 | useEffect(() => { 89 | // When the feed client changes, or the status changes issue a re-fetch 90 | feedClient.fetch({ status }); 91 | }, [feedClient, status]); 92 | 93 | const noItems = items.length === 0; 94 | const requestInFlight = isRequestInFlight(networkStatus); 95 | 96 | // Handle fetching more once we reach the bottom of the list 97 | const onBottomCallback = useCallback(() => { 98 | if (!requestInFlight && pageInfo.after) { 99 | feedClient.fetchNextPage(); 100 | } 101 | }, [requestInFlight, pageInfo, feedClient]); 102 | 103 | // Once we scroll to the bottom of the view we want to automatically fetch 104 | // more items for the feed and bring them into the list 105 | useOnBottomScroll({ 106 | ref: containerRef, 107 | callback: onBottomCallback, 108 | offset: 70, 109 | }); 110 | 111 | return ( 112 |
115 | {header || 116 | renderHeader({ 117 | setFilterStatus: setStatus, 118 | filterStatus: status, 119 | onMarkAllAsReadClick, 120 | })} 121 | 122 |
123 | {networkStatus === NetworkStatus.loading && ( 124 | 125 | )} 126 | 127 |
128 | {networkStatus !== NetworkStatus.loading && 129 | items.map((item: FeedItem) => 130 | renderItem({ item, onItemClick: onNotificationClick }) 131 | )} 132 |
133 | 134 | {networkStatus === NetworkStatus.fetchMore && ( 135 | 136 | )} 137 | 138 | {!requestInFlight && noItems && EmptyComponent} 139 |
140 | 141 | {settings?.features.branding_required && ( 142 | 147 | )} 148 |
149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /src/components/NotificationCell/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rnf-avatar-bg-color: #ef8476; 3 | --rnf-avatar-size: 32px; 4 | --rnf-avatar-initials-font-size: var(--rnf-font-size-md); 5 | --rnf-avatar-initials-line-height: var(--rnf-font-size-lg); 6 | --rnf-avatar-initials-color: #fff; 7 | --rnf-notification-cell-border-bottom-color: #e4e8ee; 8 | --rnf-notification-cell-padding: var(--rnf-spacing-3); 9 | --rnf-notification-cell-active-bg-color: #f1f6fc; 10 | --rnf-notification-cell-unread-dot-size: 6px; 11 | --rnf-notification-cell-unread-dot-bg-color: #80c7f5; 12 | --rnf-notification-cell-unread-dot-border-color: #3192e3; 13 | --rnf-notification-cell-content-color: var(--rnf-color-gray-900); 14 | --rnf-notification-cell-content-font-size: var(--rnf-font-size-sm); 15 | --rnf-notification-cell-content-line-height: var(--rnf-font-size-lg); 16 | --rnf-archive-notification-btn-bg-color: var(--rnf-color-gray-400); 17 | --rnf-archive-notification-btn-bg-color-active: var(--rnf-color-gray-500); 18 | } 19 | 20 | /* Avatar */ 21 | 22 | .rnf-avatar { 23 | background-color: var(--rnf-avatar-bg-color); 24 | border-radius: var(--rnf-avatar-size); 25 | width: var(--rnf-avatar-size); 26 | height: var(--rnf-avatar-size); 27 | flex-shrink: 0; 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | overflow: hidden; 32 | } 33 | 34 | .rnf-avatar__initials { 35 | font-size: var(--rnf-avatar-initials-font-size); 36 | line-height: var(--rnf-avatar-initials-line-height); 37 | color: var(--rnf-avatar-initials-color); 38 | } 39 | 40 | .rnf-avatar__image { 41 | object-fit: cover; 42 | width: var(--rnf-avatar-size); 43 | height: var(--rnf-avatar-size); 44 | } 45 | 46 | /* Notification cell */ 47 | 48 | .rnf-notification-cell { 49 | background-color: transparent; 50 | position: relative; 51 | border-bottom: 1px solid var(--rnf-notification-cell-border-bottom-color); 52 | } 53 | 54 | .rnf-notification-cell:last-child { 55 | border-bottom-color: transparent; 56 | } 57 | 58 | .rnf-notification-cell:hover, 59 | .rnf-notification-cell:focus, 60 | .rnf-notification-cell:active { 61 | background-color: var(--rnf-notification-cell-active-bg-color); 62 | outline: none; 63 | } 64 | 65 | .rnf-notification-cell__inner { 66 | border: none; 67 | appearance: none; 68 | margin: 0; 69 | width: 100%; 70 | text-decoration: none; 71 | display: flex; 72 | padding: var(--rnf-notification-cell-padding); 73 | cursor: pointer; 74 | text-align: left; 75 | justify-content: flex-start; 76 | } 77 | 78 | .rnf-notification-cell__unread-dot { 79 | position: absolute; 80 | top: var(--rnf-notification-cell-unread-dot-size); 81 | left: var(--rnf-notification-cell-unread-dot-size); 82 | width: var(--rnf-notification-cell-unread-dot-size); 83 | height: var(--rnf-notification-cell-unread-dot-size); 84 | border-radius: var(--rnf-notification-cell-unread-dot-size); 85 | background-color: var(--rnf-notification-cell-unread-dot-bg-color); 86 | border: 1px solid var(--rnf-notification-cell-unread-dot-border-color); 87 | } 88 | 89 | .rnf-notification-cell__content-outer { 90 | margin-left: var(--rnf-spacing-3); 91 | } 92 | 93 | .rnf-notification-cell__content { 94 | color: var(--rnf-notification-cell-content-color); 95 | display: block; 96 | font-weight: var(--rnf-font-weight-normal); 97 | font-size: var(--rnf-notification-cell-content-font-size); 98 | line-height: var(--rnf-notification-cell-content-line-height); 99 | margin-bottom: var(--rnf-spacing-1); 100 | word-break: normal; 101 | word-wrap: break-word; 102 | } 103 | 104 | .rnf-notification-cell__content h1, 105 | .rnf-notification-cell__content h2, 106 | .rnf-notification-cell__content h3, 107 | .rnf-notification-cell__content h4 { 108 | font-weight: var(--rnf-font-weight-semibold); 109 | margin-bottom: 0.5em; 110 | } 111 | 112 | .rnf-notification-cell__content h1 { 113 | font-size: var(--rnf-font-size-2xl); 114 | } 115 | 116 | .rnf-notification-cell__content h2 { 117 | font-size: var(--rnf-font-size-xl); 118 | } 119 | 120 | .rnf-notification-cell__content h3 { 121 | font-size: var(--rnf-font-size-lg); 122 | } 123 | 124 | .rnf-notification-cell__content h4 { 125 | font-size: var(--rnf-font-size-md); 126 | } 127 | 128 | .rnf-notification-cell__content p { 129 | margin: 0 0 0.75em 0; 130 | } 131 | 132 | .rnf-notification-cell__content p:last-child { 133 | margin-bottom: 0; 134 | } 135 | 136 | .rnf-notification-cell__content blockquote { 137 | border-left: 3px solid var(--rnf-color-gray-300); 138 | padding-left: var(--rnf-spacing-3); 139 | line-height: var(--rnf-font-size-xl); 140 | margin: 0; 141 | } 142 | 143 | .rnf-notification-cell__content strong { 144 | font-weight: var(--rnf-font-weight-semibold); 145 | } 146 | 147 | .rnf-notification-cell__timestamp { 148 | display: block; 149 | color: var(--rnf-color-gray-300); 150 | font-size: var(--rnf-font-size-sm); 151 | font-weight: var(--rnf-font-weight-normal); 152 | line-height: var(--rnf-font-size-lg); 153 | } 154 | 155 | .rnf-notification-cell__child-content { 156 | margin: 0.75em 0 0.5em 0; 157 | } 158 | 159 | /* Archive button */ 160 | 161 | .rnf-archive-notification-btn { 162 | background-color: transparent; 163 | appearance: none; 164 | user-select: none; 165 | border: none; 166 | opacity: 0; 167 | width: 24px; 168 | height: 24px; 169 | cursor: pointer; 170 | margin-left: auto; 171 | color: var(--rnf-archive-notification-btn-bg-color); 172 | padding: var(--rnf-spacing-1) var(--rnf-spacing-2); 173 | transition: color 0.1s ease-in-out, opacity 0.2s ease-in-out; 174 | } 175 | 176 | .rnf-notification-cell:focus .rnf-archive-notification-btn, 177 | .rnf-notification-cell:hover .rnf-archive-notification-btn, 178 | .rnf-notification-cell:active .rnf-archive-notification-btn { 179 | opacity: 1; 180 | } 181 | 182 | .rnf-archive-notification-btn:focus, 183 | .rnf-archive-notification-btn:hover, 184 | .rnf-archive-notification-btn:active { 185 | outline: none; 186 | opacity: 1; 187 | color: var(--rnf-archive-notification-btn-bg-color-active); 188 | } 189 | 190 | /* Tooltip */ 191 | 192 | .rnf-tooltip { 193 | background-color: var(--rnf-color-gray-700); 194 | border-radius: 4px; 195 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18); 196 | color: #fff; 197 | display: flex; 198 | flex-direction: column; 199 | padding: var(--rnf-spacing-1) var(--rnf-spacing-2); 200 | font-size: var(--rnf-font-size-xs); 201 | line-height: var(--rnf-font-size-s); 202 | font-weight: var(--rnf-font-weight-medium); 203 | transition: opacity 0.3s; 204 | z-index: 9999; 205 | } 206 | 207 | /* Themes */ 208 | 209 | .rnf-notification-cell--dark { 210 | --rnf-notification-cell-border-bottom-color: rgba(105, 115, 134, 0.65); 211 | --rnf-notification-cell-active-bg-color: #393b40; 212 | --rnf-notification-cell-content-color: var(--rnf-color-white-a-75); 213 | } 214 | 215 | .rnf-notification-cell--dark:last-child { 216 | border-bottom-color: transparent; 217 | } 218 | 219 | .rnf-notification-cell--dark .rnf-notification-cell__timestamp { 220 | color: var(--rnf-color-gray-500); 221 | } 222 | 223 | .rnf-archive-notification-btn--dark { 224 | --rnf-archive-notification-btn-bg-color: var(--rnf-color-gray-500); 225 | --rnf-archive-notification-btn-bg-color-active: var(--rnf-color-gray-400); 226 | } 227 | 228 | .rnf-tooltip--dark { 229 | background-color: #565a61; 230 | } 231 | 232 | @media screen and (hover: none) { 233 | .rnf-archive-notification-btn { 234 | opacity: 1; 235 | } 236 | } 237 | --------------------------------------------------------------------------------