.cursor/skills/add-channel-connect-button/reference.md
Placeholders: <Channel> = PascalCase (Discord), <channel> = kebab/lower (discord),
<provider> = the provider id string used by the API (discord).
Pick templates by model (see SKILL.md Step 0). Connection-based mirrors SlackConnectButton /
MsTeamsConnectButton; endpoint-based mirrors TelegramConnectButton.
packages/js/src/ui/icons/<Channel>Colored.tsxCopy TelegramColored.tsx and swap the SVG paths. It receives a class prop.
import type { JSX } from 'solid-js';
export const DiscordColored = (props: { class?: string }): JSX.Element => {
return (
<svg class={props.class} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
</svg>
);
};
packages/js/src/ui/api/hooks/use<Channel>Connection.tsCopy useTelegramConnection.ts. "Connected" = a subscriber endpoint exists for this
providerId + integrationIdentifier. Connection-based buttons skip this and reuse
useChannelConnection.
import { createEffect, createResource, createSignal } from 'solid-js';
import type { ChannelEndpointResponse, LinkChannelEndpointArgs } from '../../../channel-connections/types';
import { useNovu } from '../../context';
const DISCORD_PROVIDER_ID = 'discord';
export type UseDiscordConnectionOptions = {
integrationIdentifier: string;
subscriberId?: string;
};
export const useDiscordConnection = (options: UseDiscordConnectionOptions) => {
const novuAccessor = useNovu();
const [loading, setLoading] = createSignal(true);
const [endpoint, { mutate, refetch }] = createResource(
options,
async ({ integrationIdentifier, subscriberId }): Promise<ChannelEndpointResponse | null> => {
try {
if (!integrationIdentifier) {
return null;
}
const response = await novuAccessor().channelEndpoints.list({
integrationIdentifier,
providerId: DISCORD_PROVIDER_ID,
subscriberId,
limit: 1,
});
return response.data?.[0] ?? null;
} catch {
return null;
}
}
);
const link = async (args: LinkChannelEndpointArgs) => novuAccessor().channelEndpoints.link(args);
const disconnect = async (identifier: string) => {
setLoading(true);
const response = await novuAccessor().channelEndpoints.delete({ identifier });
if (!response.error) {
mutate(null);
}
setLoading(false);
return response;
};
createEffect(() => {
setLoading(endpoint.loading);
});
return { endpoint, loading, mutate, refetch, link, disconnect };
};
packages/js/src/ui/components/<channel>-connect-button/<Channel>ConnectButton.tsxRead the full sibling for the exact JSX/appearance markup (it is verbose but identical
across buttons). The two skeletons below show only what differs by model. Reuse the
buttonContent() + <Show> markup verbatim from the sibling, swapping the icon and labels.
TelegramConnectButton.tsx)const PROVIDER_ID = 'discord';
const POLL_INTERVAL_MS = 2500;
const POLL_TIMEOUT_MS = 120_000;
export const DiscordConnectButton = (props: DiscordConnectButtonProps) => {
const novuAccessor = useNovu();
const integrationIdentifier = () => props.integrationIdentifier;
const resolvedSubscriberId = () => props.subscriberId ?? novuAccessor().subscriberId;
const { endpoint, loading, disconnect, mutate, link } = useDiscordConnection({
integrationIdentifier: integrationIdentifier(),
subscriberId: props.subscriberId,
});
// ...polling with a `committed` one-shot guard, then on click:
const result = await link({ integrationIdentifier: integrationIdentifier() });
if (result.data?.url) {
window.open(result.data.url, '_blank', 'noopener,noreferrer');
startPolling(); // polls channelEndpoints.list(...) until data[0] exists
}
};
SlackConnectButton.tsx / MsTeamsConnectButton.tsx)const connectionMode = () => props.connectionMode ?? 'subscriber';
const connectionIdentifier = () =>
props.connectionIdentifier ??
buildDefaultConnectionIdentifier(DEFAULT_DISCORD_CONNECTION_IDENTIFIER, resolvedSubscriberId());
const { connection, loading, disconnect, mutate, generateConnectOAuthUrl } = useChannelConnection({
integrationIdentifier: integrationIdentifier(),
connectionIdentifier: connectionIdentifier(),
subscriberId: props.subscriberId,
});
// on click:
const result = await generateConnectOAuthUrl({
integrationIdentifier: integrationIdentifier(),
connectionIdentifier: connectionIdentifier(),
connectionMode: connectionMode(),
autoLinkUser: props.autoLinkUser ?? true,
});
if (result.data?.url) {
window.open(result.data.url, '_blank', 'noopener,noreferrer');
startPolling(); // polls channelConnections.get(connectionIdentifier) until data exists
}
Add a constant to packages/js/src/ui/components/constants.ts for the connection model:
export const DEFAULT_DISCORD_CONNECTION_IDENTIFIER = 'chconn-discord-default';
packages/jssrc/ui/components/Renderer.tsx — three edits:
import { DiscordConnectButton } from './discord-connect-button/DiscordConnectButton';
export const novuComponents = {
// ...existing
DiscordConnectButton,
};
const CHANNEL_COMPONENTS = [
// ...existing
'DiscordConnectButton',
];
src/ui/components/index.ts:
export * from './discord-connect-button/DiscordConnectButton';
src/ui/index.ts — add the props type to the existing *ConnectButtonProps type export block.
packages/reactsrc/components/<channel>-connect-button/Default<Channel>ConnectButton.tsx (copy Telegram's):
import { DiscordConnectButtonProps } from '@novu/js/ui';
import { useCallback } from 'react';
import { useNovuUI } from '../../context/NovuUIContext';
import { Mounter } from '../Mounter';
export type DefaultDiscordConnectButtonProps = Pick<
DiscordConnectButtonProps,
| 'integrationIdentifier'
| 'subscriberId'
| 'onConnectSuccess'
| 'onConnectError'
| 'onDisconnectSuccess'
| 'onDisconnectError'
| 'connectLabel'
| 'connectedLabel'
>;
export const DefaultDiscordConnectButton = (props: DefaultDiscordConnectButtonProps) => {
const { novuUI } = useNovuUI();
const mount = useCallback(
(element: HTMLElement) =>
novuUI.mountComponent({
name: 'DiscordConnectButton', // MUST match novuComponents key
props: { ...props },
element,
}),
[novuUI, props]
);
return <Mounter mount={mount} />;
};
src/components/<channel>-connect-button/<Channel>ConnectButton.tsx (copy Telegram's):
import React, { useMemo } from 'react';
import { useNovu } from '../../hooks/NovuProvider';
import { NovuUI, NovuUIOptions } from '../NovuUI';
import { withRenderer } from '../Renderer';
import { DefaultDiscordConnectButton, DefaultDiscordConnectButtonProps } from './DefaultDiscordConnectButton';
export type DiscordConnectButtonProps = DefaultDiscordConnectButtonProps &
Pick<NovuUIOptions, 'container' | 'appearance'>;
const DiscordConnectButtonInternal = withRenderer<DiscordConnectButtonProps>((props) => {
const { container, appearance, ...defaultProps } = props;
const novu = useNovu();
const options: NovuUIOptions = useMemo(
() => ({ container, appearance, options: novu.options }),
[container, appearance, novu.options]
);
return (
<NovuUI options={options} novu={novu}>
<DefaultDiscordConnectButton {...defaultProps} />
</NovuUI>
);
});
DiscordConnectButtonInternal.displayName = 'DiscordConnectButtonInternal';
export const DiscordConnectButton = React.memo((props: DiscordConnectButtonProps) => {
return <DiscordConnectButtonInternal {...props} />;
});
DiscordConnectButton.displayName = 'DiscordConnectButton';
src/components/index.ts:
export * from './discord-connect-button/DiscordConnectButton';
src/index.ts — add to both blocks:
export type { /* ... */ DiscordConnectButtonProps } from './components';
export { /* ... */ DiscordConnectButton } from './components';
Decision: bot deep-link, no workspace OAuth → endpoint-based (like Telegram).
Create:
packages/js/src/ui/icons/DiscordColored.tsx
packages/js/src/ui/api/hooks/useDiscordConnection.ts
packages/js/src/ui/components/discord-connect-button/DiscordConnectButton.tsx
packages/react/src/components/discord-connect-button/DefaultDiscordConnectButton.tsx
packages/react/src/components/discord-connect-button/DiscordConnectButton.tsx
Edit:
packages/js/src/ui/components/Renderer.tsx (+import, +novuComponents, +CHANNEL_COMPONENTS)
packages/js/src/ui/components/index.ts (+export)
packages/js/src/ui/index.ts (+DiscordConnectButtonProps type)
packages/react/src/components/index.ts (+export)
packages/react/src/index.ts (+component, +props type)
Then: pnpm build → wire into playground/nextjs → connect/disconnect test.
For a Discord OAuth/app-install flow instead, switch to the connection model: drop the
custom hook, reuse useChannelConnection, add connectionIdentifier/context/scope/
connectionMode/autoLinkUser props, add DEFAULT_DISCORD_CONNECTION_IDENTIFIER to
constants.ts, and poll channelConnections.get(...).