Plugin Anatomy
Every file of a plugin, in a real example.
Every file of packages/plugins/character-counter, the reference plugin. It shows 120/2000 above the composer while you type. Code blocks marked with a path are copied from it, and a test fails when a copy drifts.
Files
- character-counter
- manifest.jsonId, name, authors, version
- package.jsonDependencies and scripts
- rollup.config.mjsHands the build to ubd
- tsconfig.jsonMaps ~/ to src/, loads API types
- bunfig.tomlLoads the testing preset
- settings.tsSettings; the build draws the panel
- i18n
- en-US.jsonEvery string. Required; the fallback language
- src
- index.tsxThe entry: lists the features
- patches.tsxThe one feature: patchChatInput
- unbound-settings.d.tsGenerated. Commit it
- unbound-i18n.d.tsGenerated. Commit it
- components
- character-counter.tsxThe pill
- use-draft-length.tsHook that reads the draft
- libOwn logic, no patches
- limit.tsLimit and tone
- colors.tsTheme colors
- constants.tsConstants shared across files
- tests
- lib
- limit.test.tsPure logic
- colors.test.tsPure logic
- character-counter.test.tsxThe started plugin, rendered
- ADDON.mdThe addon page
- CHANGELOG.mdChanges per version
A plugin with images adds assets/; an addon page with a banner adds media/. Git ignores dist/ and node_modules/. Project Rules lists the layout rules.
Root Files
manifest.json
main names the entry inside src/. icon names one of Discord's icons. unbound.min is the oldest Unbound client the plugin runs on; older clients keep the version they have. See The Manifest.
{
"id": "unbound.character-counter",
"name": "Character Counter",
"description": "Shows the current message length and Discord's character limit in the composer.",
"authors": [
{
"name": "thororen",
"id": "848339671629299742"
},
{
"name": "creations",
"id": "209830981060788225"
},
{
"name": "adrian",
"id": "224617799434108928"
}
],
"version": "1.0.5",
"unbound": {
"min": "3.0.0"
},
"icon": "TextIcon",
"main": "index.tsx"
}Never write platforms or Capabilities. ubd build derives them and fails if you add them by hand.
Build Config
unbound reads the manifest, bundles src/, generates the settings and i18n modules, and runs the build checks.
import { unbound } from '@unbound-app/cli/rollup';
export default unbound(import.meta.url);| File | Holds |
|---|---|
package.json | "type": "module", build and test scripts. devDependencies: @unbound-app/api, @types/react, react-native, @unbound-app/cli. First-party plugins also depend on @unbound-plugins/shared; third-party plugins write those helpers themselves |
tsconfig.json | types: ["bun", "@unbound-app/api/global"] for the scope globals such as logger and addon. Maps ~/* to ./src/*, which the build also resolves. Extends the workspace ../tsconfig.json and includes src, tests and settings.ts |
bunfig.toml | Preloads the testing preset, which stands in for Unbound, Discord and the build. See Writing Tests |
[test]
# Serves the plugin's manifest, translations, settings and a fake Unbound API to every test.
preload = ["@unbound-app/api/testing/preset"]The Entry
src/index.tsx lists the features and holds no logic: no ifs, no state.
import { patcher } from '@unbound-app/api';
import { patchChatInput } from '~/patches';
export function start() {
patchChatInput();
}
export function stop() {
patcher.unpatchAll();
}| Export | Does |
|---|---|
start | Calls each feature in order. A feature checks its own needs, so start never branches |
stop | patcher.unpatchAll() removes this plugin's patches only. Then undo what the patcher does not own (timers kept outside the scope, outside listeners, store redraws, saved state), in the order it matters |
A feature that throws is a bug: Unbound logs the failed start and disposes the scope. See Lifecycle for what stop still undoes.
The device matrix reads a self-test after a restart. Unbound builds it from the warnings and errors the plugin's logger wrote during start: the first one fails it, with that line as the detail. Export selfTest only to check more, such as that something is drawn on screen.
Folder Layout
Never deeper than src/<folder>/<file>, except platform code in src/<folder>/ios/<file> and src/<folder>/android/<file>, such as native/ios/views.ts or lib/android/colors.ts. At the top level, at most:
| Entry | Holds |
|---|---|
index.tsx, settings.tsx | The entry, and a settings panel when the plugin draws its own |
patches.ts, commands.ts | Each kind in one file while it is small |
native/ | hooks.ts, views.ts, worklets.ts, calls.ts; ios/ and android/ for platform-native files |
flux/ | interceptors.ts, listeners.ts |
components/ | React components and their hooks |
lib/ | The plugin's own logic: data, network, storage, helpers. No Discord patches; tests reach it directly |
A file that grows too big becomes a folder of named files: patches.ts becomes patches/message-rows.ts, native/worklets.ts becomes native/android/worklets.ts and native/ios/worklets.ts, selected in native/worklets.ts with definePlatform. A platform split always uses the folders, never -ios or -android file names. Function names carry their kind, patchX, interceptX, listenToX, registerX, hookX or drawX, so a file moves without renames.
A Feature
A feature is one function. It checks its own needs first, and each failed check returns early:
- A setting that is off returns silently.
- A missing module logs one
logger.warnnaming the feature and what moved, then returns. - Then it patches. No unpatch variables:
stopremoves every patch at once.
Character Counter has one feature, patchChatInput. The JSDoc names what it patches and reads.
import { cloneElement, isValidElement, type ReactNode } from 'react';
import { flux, metro, patcher } from '@unbound-app/api';
import { CharacterCounter } from '~/components/character-counter';
type ContainerProps = { channel?: { id?: string }; channelId?: string };
/**
* @description Shows the counter above the composer without changing the composer's layout, by patching the render of
* Discord's `FloatingChatInputContainer`; the counter reads the draft from `ChatInputs`.
*/
export function patchChatInput(): void {
const ChatInputs = metro.findByProps('getBestActiveInputForChannelId');
const FloatingChatInputContainer = metro.findByFilePath(
'modules/chat_input/native/FloatingChatInputContainer.tsx',
);
if (!ChatInputs || !FloatingChatInputContainer) {
logger.warn('Counter skipped: Discord moved ChatInputs or FloatingChatInputContainer.');
return;
}
const SelectedChannelStore = flux.getStore('SelectedChannel');
patcher.react.after(FloatingChatInputContainer, 'default', ({ props, result }) => {
const container: ContainerProps = props;
const channelId =
container.channel?.id ?? container.channelId ?? SelectedChannelStore.getChannelId();
if (!channelId || !isValidElement<{ children?: ReactNode }>(result)) return;
return cloneElement(
result,
{},
<>
{result.props.children}
<CharacterCounter
key='character-counter'
channelId={channelId}
inputs={ChatInputs}
/>
</>,
);
});
}| Habit | Why |
|---|---|
| Lookups inside the feature | A lazy lookup returns undefined when this Discord build is known to lack the module, so the check is a plain if. A feature that is off makes no lookups |
Patch 'default' on the module | Reading the component itself would load the module at start |
Add a child, keep children | Other plugins may patch the same component |
| Pass handles down as props | The component needs no lookup of its own. Only handles several features share go in a modules.ts |
flux.getStore is typed and never undefined. Prefer metro.api, metro.common and metro.stores when they hold what you need; the component reads metro.common.Theme. When Discord renames a module, the fix is one line at the top of the feature. See Finding Modules.
lib/limit.ts
Decision logic goes in lib/, in plain files with no API imports, so tests reach it directly. lib/colors.ts does the same for theme colors.
import type { FluxUser } from '@unbound-app/api/flux';
export type CounterTone = 'primary' | 'muted' | 'yellow' | 'orange' | 'red';
/**
* @description Discord's message length limit for a user.
* @param user The signed-in user; a `premiumType` of `2` is Nitro.
* @returns 4000 for Nitro, otherwise 2000.
*/
export function characterLimit(user: Pick<FluxUser, 'premiumType'> | undefined): number {
return user?.premiumType === 2 ? 4000 : 2000;
}
/**
* @description Picks the counter's color as the message approaches the limit.
* @param length The message length.
* @param limit The user's message length limit.
* @param colorEffects Whether the warning colors are turned on.
* @returns `primary` when warnings are off, otherwise a tone for the share of the limit used.
*/
export function counterTone(length: number, limit: number, colorEffects: boolean): CounterTone {
if (!colorEffects) return 'primary';
const used = length / limit;
if (used < 0.5) return 'muted';
if (used < 0.75) return 'yellow';
if (used < 0.9) return 'orange';
return 'red';
}components
The pill reads the draft, the Nitro tier and the user's setting:
const length = useDraftLength(inputs, channelId);
const limit = flux.useStore(UserStore, (store) => characterLimit(store.getCurrentUser()));
const colorEffects = useSetting('colorEffects');format('COUNTER_LABEL', { length, limit }) gives its accessibility label. The build redraws it when the language changes.
A constant read by more than one file goes in src/lib/constants.ts. A value one file uses stays there. Character Counter's colors try Discord's theme tokens in order, then a fixed fallback:
// Each tone tries Discord's theme tokens in order, then a fixed color when the theme has none.
export const TONE_COLORS = {
primary: { tokens: ['BRAND_330', 'BRAND_500'], fallback: '#5865f2' },
muted: { tokens: ['TEXT_NORMAL'], fallback: '#f2f3f5' },
yellow: { tokens: ['YELLOW_330', 'YELLOW_300'], fallback: '#fee75c' },
orange: { tokens: ['ORANGE_330', 'ORANGE_300'], fallback: '#f0b232' },
red: { tokens: ['RED_360', 'RED_400'], fallback: '#ed4245' },
};
export const COUNTER_HEIGHT = 20;Settings
settings.ts sits beside src/ and never ships in the bundle. This one setting becomes a switch with no panel code.
import { defineSettings, setting } from '@unbound-app/settings/schema';
export default defineSettings(
{
colorEffects: setting.boolean({
default: true,
title: 'COLOR_THRESHOLDS',
subtitle: 'COLOR_THRESHOLDS_SUBTITLE',
}),
},
{ title: 'APPEARANCE' },
);title and subtitle are message keys. The generated src/unbound-settings.d.ts is why useSetting('colorEffects') returns a boolean. See Settings and Storage.
Translations
i18n/en-US.json holds every string. Other languages sit beside it and may leave keys out.
{
"APPEARANCE": "Appearance",
"COLOR_THRESHOLDS": "Color thresholds",
"COLOR_THRESHOLDS_SUBTITLE": "Warn as the message approaches Discord’s character limit.",
"COUNTER_LABEL": "{length} of {limit} characters"
}The generated src/unbound-i18n.d.ts makes a missing key a type error in format, setting titles and toasts. Commit both generated files so your editor knows the keys before the first build. See Translations.
Tests
Tests sit in tests/, named after what they cover.
| Kind | How |
|---|---|
Pure logic (lib/limit.test.ts, lib/colors.test.ts) | Import and call it. The preset is needed only for ~/ |
Started plugin (character-counter.test.tsx) | Register the modules it looks up in beforeEach, call startPlugin(), render the patched container, read the pill |
test('shows the length over the limit while a message is written', () => {
draft.text = 'hello';
const counter = renderComposer().find('Text');
expect(counter.text()).toBe('5/2000');
expect(counter.props.accessibilityLabel).toBe('5 of 2000 characters');
});The preset forgets registered modules before each test. metro.markMissing makes a lookup return undefined, as on a Discord build that lacks the module, to test a feature's warning. See Writing Tests.
Images
Images go in assets/ beside src/. Importing one gives its asset id, and the build copies it into dist/. See Images.
Addon Page
ADDON.md has no title; the Marketplace takes the title and summary from the manifest.
---
banner: media/banner.png
---
## :sparkle: What it does
- :text: Shows length over your limit, like `120/2000`, while you type.
- :star: The limit is 4000 characters with Nitro and 2000 without.
- :eye: Hides while the chat input is empty and never changes its layout.
- :arrow-up: Moves up to clear the slowmode notice in channels that have one.
## :gear: Settings
- :color-palette: **Color thresholds** turns the counter yellow at 50% of the limit, orange at 75% and red at 90%. Off, it stays in Discord's brand color. On by default.- The frontmatter names the banner and an optional gallery, both in
media/. - No frontmatter builds with a warning, and the Marketplace shows no banner.
bun run plugins:bannersdraws first-party banners from the manifest and leaves hand-made ones alone.CHANGELOG.mdlists changes newest first. Its version matchesmanifest.json.
See Addon Page.