Skip to content

Commit

Permalink
Add Notifications (#161)
Browse files Browse the repository at this point in the history
* Scaffold Knock integration

* Misc fixes, regen lockfile

* Collab on Notifications (#388)

* update env vars

* update notif provider

* add secret key for server sdk

* create feed component

* make provider client

---------

Co-authored-by: Jeff Everhart <jeffeverhart383@gmail.com>

* Extract notifications into new package

* Update feed.tsx

* Move provider to package

* Scaffold doc

* Update env.ts

* change key and stub docs (#393)

* use public api key for feed

* stub out docs

* add link to keys docs

* Fix keys

* Add notifications feed to app

* Update notifications.mdx

* Temporary shadcn/ui patch

shadcn-ui/ui#6334

---------

Co-authored-by: Jeff Everhart <jeffeverhart383@gmail.com>
  • Loading branch information
haydenbleasel and JEverhart383 authored Jan 12, 2025
1 parent 749b103 commit 0e9560e
Show file tree
Hide file tree
Showing 22 changed files with 923 additions and 23 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
echo "LIVEBLOCKS_SECRET=sk_test" >> apps/app/.env.local
echo "BASEHUB_TOKEN=${{ secrets.BASEHUB_TOKEN }}" >> apps/app/.env.local
echo "VERCEL_PROJECT_PRODUCTION_URL=http://localhost:3002" >> apps/app/.env.local
echo "KNOCK_API_KEY=test" >> apps/app/.env.local
echo "KNOCK_FEED_CHANNEL_ID=test" >> apps/app/.env.local
echo "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_JA==" >> apps/app/.env.local
echo "NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in" >> apps/app/.env.local
Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3002"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand Down
5 changes: 4 additions & 1 deletion apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""
KNOCK_SECRET_API_KEY=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand All @@ -26,4 +29,4 @@ NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST=""
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
12 changes: 11 additions & 1 deletion apps/app/app/(authenticated)/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { OrganizationSwitcher, UserButton } from '@repo/auth/client';
import { ModeToggle } from '@repo/design-system/components/mode-toggle';
import { Button } from '@repo/design-system/components/ui/button';
import {
Collapsible,
CollapsibleContent,
Expand Down Expand Up @@ -33,8 +34,10 @@ import {
useSidebar,
} from '@repo/design-system/components/ui/sidebar';
import { cn } from '@repo/design-system/lib/utils';
import { NotificationsTrigger } from '@repo/notifications/components/trigger';
import {
AnchorIcon,
BellIcon,
BookOpenIcon,
BotIcon,
ChevronRightIcon,
Expand Down Expand Up @@ -331,7 +334,14 @@ export const GlobalSidebar = ({ children }: GlobalSidebarProperties) => {
},
}}
/>
<ModeToggle />
<div className="flex shrink-0 items-center gap-px">
<ModeToggle />
<NotificationsTrigger>
<Button variant="ghost" size="icon" className="shrink-0">
<BellIcon size={16} className="text-muted-foreground" />
</Button>
</NotificationsTrigger>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
Expand Down
27 changes: 15 additions & 12 deletions apps/app/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { env } from '@/env';
import { auth, currentUser } from '@repo/auth/server';
import { SidebarProvider } from '@repo/design-system/components/ui/sidebar';
import { showBetaFeature } from '@repo/feature-flags';
import { NotificationsProvider } from '@repo/notifications/components/provider';
import { secure } from '@repo/security';
import type { ReactNode } from 'react';
import { PostHogIdentifier } from './components/posthog-identifier';
Expand All @@ -21,21 +22,23 @@ const AppLayout = async ({ children }: AppLayoutProperties) => {
const betaFeature = await showBetaFeature();

if (!user) {
redirectToSignIn();
return redirectToSignIn();
}

return (
<SidebarProvider>
<GlobalSidebar>
{betaFeature && (
<div className="m-4 rounded-full bg-success p-1.5 text-center text-sm text-success-foreground">
Beta feature now available
</div>
)}
{children}
</GlobalSidebar>
<PostHogIdentifier />
</SidebarProvider>
<NotificationsProvider userId={user.id}>
<SidebarProvider>
<GlobalSidebar>
{betaFeature && (
<div className="m-4 rounded-full bg-success p-1.5 text-center text-sm text-success-foreground">
Beta feature now available
</div>
)}
{children}
</GlobalSidebar>
<PostHogIdentifier />
</SidebarProvider>
</NotificationsProvider>
);
};

Expand Down
2 changes: 2 additions & 0 deletions apps/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { keys as database } from '@repo/database/keys';
import { keys as email } from '@repo/email/keys';
import { keys as flags } from '@repo/feature-flags/keys';
import { keys as core } from '@repo/next-config/keys';
import { keys as notifications } from '@repo/notifications/keys';
import { keys as observability } from '@repo/observability/keys';
import { keys as security } from '@repo/security/keys';
import { keys as webhooks } from '@repo/webhooks/keys';
Expand All @@ -19,6 +20,7 @@ export const env = createEnv({
database(),
email(),
flags(),
notifications(),
observability(),
security(),
webhooks(),
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@repo/design-system": "workspace:*",
"@repo/feature-flags": "workspace:*",
"@repo/next-config": "workspace:*",
"@repo/notifications": "workspace:*",
"@repo/observability": "workspace:*",
"@repo/security": "workspace:*",
"@repo/seo": "workspace:*",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ ARCJET_KEY=""
SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
# VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3001"
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3001"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand Down
Binary file added docs/images/authors/knock/j_everhart383.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/authors/knock/logo.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/mint.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"packages/next-config/bundle-analysis"
]
},
"packages/notifications",
"packages/payments",
{
"group": "Security",
Expand Down
72 changes: 72 additions & 0 deletions docs/packages/notifications.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: Notifications
description: In-app notifications for your users.
---

import { Authors } from '/snippets/authors.mdx';

<Authors data={[{
user: {
name: 'Hayden Bleasel',
id: 'haydenbleasel',
},
company: {
name: 'next-forge',
id: 'next-forge',
},
}, {
user: {
name: 'Jeff Everhart',
id: 'j_everhart383',
},
company: {
name: 'Knock',
id: 'knock',
},
}]} />

next-forge offers a notifications package that allows you to send in-app notifications to your users. By default, it uses [Knock](https://knock.app/), a cross-channel notification platform that supports in-app, email, SMS, push, and chat notifications. Knock allows you to centralize your notification logic and templates in one place and [orchestrate complex workflows](https://docs.knock.app/designing-workflows/overview) with things like branching, batching, throttling, delays, and conditional sending.

## Setup

To use the notifications package, you need to add the required environment variables to your project, as specified in the `packages/notifications/keys.ts` file.

## In-app notifications feed

To render an in-app notifications feed, import the `NotificationsTrigger` component from the `@repo/notifications` package and use it in your app. We've already added this to the sidebar in the example app:

```tsx apps/app/app/(authenticated)/components/sidebar.tsx
import { NotificationsTrigger } from '@repo/notifications/components/trigger';

<NotificationsTrigger>
<Button variant="ghost" size="icon" className="shrink-0">
<BellIcon size={16} className="text-muted-foreground" />
</Button>
</NotificationsTrigger>
```

Pressing the button will open the in-app notifications feed, which displays all of the notifications for the current user.

## Send a notification

Knock sends notifications using workflows. To send an in-app notification, create a new [workflow](https://docs.knock.app/concepts/workflows) in the Knock dashboard that uses the [`in-app` channel provider](https://docs.knock.app/integrations/in-app/knock) and create a corresponding message template.

Then you can [trigger that workflow](https://docs.knock.app/send-notifications/triggering-workflows) for a particular user in your app, passing in the necessary data to populate the message template:

```tsx notify.ts
import { notifications } from '@repo/notifications';

await notifications.workflows.trigger('workflow-key', {
recipients: [{
id: 'user-id',
email: 'user-email',
}],
data: {
message: 'Hello, world!',
},
});
```

## Multi-channel notifications

Using Knock, you can add additional channel providers to your workflow to send notifications via email, SMS, push, or chat. To do this, create a new [channel provider](https://docs.knock.app/integrations) in the Knock dashboard, follow any configuration instructions for that provider, and add it to your workflow as a channel step.
2 changes: 1 addition & 1 deletion packages/design-system/components/mode-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ModeToggle = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
variant="ghost"
size="icon"
className="shrink-0 text-foreground"
>
Expand Down
4 changes: 2 additions & 2 deletions packages/design-system/components/ui/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ function Calendar({
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
IconLeft: ({ className, children, ...props }) => (
<ChevronLeftIcon className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
IconRight: ({ className, children, ...props }) => (
<ChevronRightIcon className={cn("h-4 w-4", className)} {...props} />
),
}}
Expand Down
30 changes: 30 additions & 0 deletions packages/notifications/components/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';

import { KnockFeedProvider, KnockProvider } from '@knocklabs/react';
import type { ReactNode } from 'react';
import { keys } from '../keys';

const knockApiKey = keys().NEXT_PUBLIC_KNOCK_API_KEY;
const knockFeedChannelId = keys().NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID;

type NotificationsProviderProps = {
children: ReactNode;
userId: string;
};

export const NotificationsProvider = ({
children,
userId,
}: NotificationsProviderProps) => {
if (!knockApiKey || !knockFeedChannelId) {
return children;
}

return (
<KnockProvider apiKey={knockApiKey} userId={userId}>
<KnockFeedProvider feedId={knockFeedChannelId}>
{children}
</KnockFeedProvider>
</KnockProvider>
);
};
48 changes: 48 additions & 0 deletions packages/notifications/components/trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { NotificationFeedPopover } from '@knocklabs/react';
import { useRef, useState } from 'react';
import type { ReactElement, RefObject } from 'react';
import { keys } from '../keys';

// Required CSS import, unless you're overriding the styling
import '@knocklabs/react/dist/index.css';

type NotificationsTriggerProperties = {
children: ReactElement;
};

export const NotificationsTrigger = ({
children,
}: NotificationsTriggerProperties) => {
const [isVisible, setIsVisible] = useState(false);
const notifButtonRef = useRef<HTMLDivElement>(null);

if (!keys().NEXT_PUBLIC_KNOCK_API_KEY) {
return null;
}

return (
<>
{/* biome-ignore lint/nursery/noStaticElementInteractions: "avoid nested buttons" */}
<div
onClick={() => setIsVisible(!isVisible)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setIsVisible(!isVisible);
}
}}
ref={notifButtonRef}
>
{children}
</div>
{notifButtonRef.current && (
<NotificationFeedPopover
buttonRef={notifButtonRef as RefObject<HTMLElement>}
isVisible={isVisible}
onClose={() => setIsVisible(false)}
/>
)}
</>
);
};
6 changes: 6 additions & 0 deletions packages/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Knock } from '@knocklabs/node';
import { keys } from './keys';

const key = keys().KNOCK_SECRET_API_KEY;

export const notifications = new Knock(key);
19 changes: 19 additions & 0 deletions packages/notifications/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
KNOCK_SECRET_API_KEY: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_KNOCK_API_KEY: z.string().min(1).optional(),
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID: z.string().min(1).optional(),
},
runtimeEnv: {
NEXT_PUBLIC_KNOCK_API_KEY: process.env.NEXT_PUBLIC_KNOCK_API_KEY,
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID:
process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID,
KNOCK_SECRET_API_KEY: process.env.KNOCK_SECRET_API_KEY,
},
});
23 changes: 23 additions & 0 deletions packages/notifications/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@repo/notifications",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@knocklabs/node": "^0.6.13",
"@knocklabs/react": "^0.2.29",
"@t3-oss/env-nextjs": "^0.11.1",
"react": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"typescript": "^5.7.2",
"@types/node": "22.10.5",
"@types/react": "19.0.2",
"@types/react-dom": "^19.0.2"
}
}
8 changes: 8 additions & 0 deletions packages/notifications/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Loading

0 comments on commit 0e9560e

Please sign in to comment.