From 621dfb74de64ba323e8cf91dd2601639c76371e1 Mon Sep 17 00:00:00 2001 From: sadan4 <117494111+sadan4@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:27:31 -0400 Subject: [PATCH] feat(plugin): NoFollowForwards (#1179) * make plugin * merge with forwardAnywhere * [autofix.ci] apply automated fixes * update desc and name * run markdown formatter on .rules * remove type only rule * use mouse event over keyboard event * tighten lookahead assertion * fix lint * properly mark restart required * tighten lookahead even more * Update index.ts * Update index.ts * Update index.ts * add sadan as dev --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- .rules | 223 +++++++++--------- src/components/Icons.tsx | 1 - src/equicordplugins/betterForwards/README.md | 13 + .../index.ts | 43 +++- src/equicordplugins/fileUpload/settings.tsx | 29 +-- .../musicControls/settings.tsx | 3 +- .../richPresence/SettingsPanel.tsx | 36 +-- src/plugins/reviewDB/reviewDbApi.ts | 8 +- src/plugins/viewRaw/index.tsx | 1 - 9 files changed, 206 insertions(+), 151 deletions(-) create mode 100644 src/equicordplugins/betterForwards/README.md rename src/equicordplugins/{forwardAnywhere => betterForwards}/index.ts (55%) diff --git a/.rules b/.rules index 791aa03f8..96dece78f 100644 --- a/.rules +++ b/.rules @@ -28,7 +28,6 @@ - Never boxed primitives (`Number`, `String`, `Boolean`, `Object`, `Function`, `{}`). - Never `namespace`. Modules only. - Never `../../` when `@alias` exists. -- `import type` for type-only imports, including from `"react"`. Plain `import { ReactNode }` is violation. - `satisfies T` over `as T` when value already conforms. `as` only for genuine narrowing. ## Plugin Policy @@ -45,15 +44,15 @@ Does NOT get merged: ## Plugin Structure - Every NEW source file starts with SPDX header: - ``` - /* - * Vencord, a Discord client mod - * Copyright (c) Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - ``` - Header says `Vencord, a Discord client mod` even on new Equicord-only files. Equicord is a Vencord fork, upstream attribution is kept across the whole tree. Don't change it to "Equicord". - Older files carry the full GPL preamble. Leave existing headers alone, don't rewrite them. + ``` + /* + * Vencord, a Discord client mod + * Copyright (c) Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + ``` + Header says `Vencord, a Discord client mod` even on new Equicord-only files. Equicord is a Vencord fork, upstream attribution is kept across the whole tree. Don't change it to "Equicord". + Older files carry the full GPL preamble. Leave existing headers alone, don't rewrite them. - Folder per plugin. `src/plugins//index.tsx` or `src/equicordplugins//index.tsx`. Single-file plugins still get a folder. - Folder suffixes the build filters on: `.desktop`, `.web`, `.dev`, `.discordDesktop`, `.vesktop`, `.equibop`. - `export default definePlugin({ ... })`. Never named. @@ -79,22 +78,22 @@ Imperative `add*`/`remove*` only when: multiple items of same kind, conditional, Imperative usage MUST list the API in `dependencies`: -| API | Dependency | -|---|---| -| ContextMenu | `ContextMenuAPI` | -| MessageEvents | `MessageEventsAPI` | -| ChatButtons | `ChatInputButtonAPI` | -| Badges | `BadgeAPI` | +| API | Dependency | +| -------------------- | ------------------------- | +| ContextMenu | `ContextMenuAPI` | +| MessageEvents | `MessageEventsAPI` | +| ChatButtons | `ChatInputButtonAPI` | +| Badges | `BadgeAPI` | | MemberListDecorators | `MemberListDecoratorsAPI` | -| MessageAccessories | `MessageAccessoriesAPI` | -| MessageDecorations | `MessageDecorationsAPI` | -| MessagePopover | `MessagePopoverAPI` | -| NicknameIcons | `NicknameIconsAPI` | -| HeaderBar | `HeaderBarAPI` | -| UserArea | `UserAreaAPI` | -| ProfileCollections | `ProfileCollectionsAPI` | -| ProfileSections | `ProfileSectionsAPI` | -| AudioPlayer | `AudioPlayerAPI` | +| MessageAccessories | `MessageAccessoriesAPI` | +| MessageDecorations | `MessageDecorationsAPI` | +| MessagePopover | `MessagePopoverAPI` | +| NicknameIcons | `NicknameIconsAPI` | +| HeaderBar | `HeaderBarAPI` | +| UserArea | `UserAreaAPI` | +| ProfileCollections | `ProfileCollectionsAPI` | +| ProfileSections | `ProfileSectionsAPI` | +| AudioPlayer | `AudioPlayerAPI` | Declarative fields auto-enable these. Imperative doesn't. @@ -167,33 +166,33 @@ Declarative fields auto-enable these. Imperative doesn't. - Prefer multiple small patches over one with broad find and many replacements. - In DEV, failing replacements auto-diff 200 chars around the match site. Read it before retrying. - Never reference build flags literally inside `replace`. They only exist in Equicord's bundle. Interpolate outside: - ```js - // BAD - replace: "IS_WEB?foo:bar" - // GOOD - replace: IS_WEB ? "foo" : "bar" - // OK - replace: `${IS_WEB}?foo:bar` - ``` + ```js + // BAD + replace: "IS_WEB?foo:bar"; + // GOOD + replace: IS_WEB ? "foo" : "bar"; + // OK + replace: `${IS_WEB}?foo:bar`; + ``` - Keep `replace` small: wrap a single function via patch + `$self`, push logic to plugin TS. A 3-line `$self.handleClick(arguments[0])` beats a 30-line render rebuild. ### Examples ```js // clean replacement -find: "#{intl::EXAMPLE_KEY_A}),icon:" -match: /#{intl::EXAMPLE_KEY_A}\)/ -replace: "$self.exampleFn(arguments[0]))" +find: "#{intl::EXAMPLE_KEY_A}),icon:"; +match: /#{intl::EXAMPLE_KEY_A}\)/; +replace: "$self.exampleFn(arguments[0]))"; // appending -find: "#{intl::EXAMPLE_KEY_B}" -match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/ -replace: "$&,$self.exampleRender(arguments[0].guild)" +find: "#{intl::EXAMPLE_KEY_B}"; +match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/; +replace: "$&,$self.exampleRender(arguments[0].guild)"; // wrapping with lookbehind -find: "#{intl::EXAMPLE_KEY_C}" -match: /(?<="aria-atomic":!0,children:)\i/ -replace: "$self.exampleWrap({ children: $& })" +find: "#{intl::EXAMPLE_KEY_C}"; +match: /(?<="aria-atomic":!0,children:)\i/; +replace: "$self.exampleWrap({ children: $& })"; ``` ## Webpack access @@ -245,9 +244,11 @@ replace: "$self.exampleWrap({ children: $& })" - Subprocess: `spawn` with argv arrays. Never `exec` a renderer-derived string. Never `shell: true` with untrusted input. - `shell.openExternal`: only with https allowlist. - Renderer side: - ```ts - const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative; - ``` + ```ts + const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative< + typeof import("./native") + >; + ``` - `pluginHelpers` key matches `definePlugin.name` exactly (case-sensitive). - Renderer must never import `fs`, `path`, `net`, `http`, `https`, `child_process`, `electron`. Go through `native.ts`. - File pickers: `saveFile`/`chooseFile` from `@utils/web`. Don't re-implement via `dialog.show*` over IPC. @@ -260,18 +261,18 @@ replace: "$self.exampleWrap({ children: $& })" - Long-lived component timers use `useFixedTimer`/`useTimer` from `@utils/react`. - Every `add*` in `start()` pairs with `remove*` in `stop()`. - Idle timer pattern: - ```ts - let intervalId: number | undefined; - start() { intervalId = setInterval(tick, 30_000); } - stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } } - ``` + ```ts + let intervalId: number | undefined; + start() { intervalId = setInterval(tick, 30_000); } + stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } } + ``` ## Storage - Persistent state: `DataStore` from `@api/DataStore` (idb-backed). Never `localStorage`/`sessionStorage`/`indexedDB`. - Plain settings: `definePluginSettings`. Internal hidden state: `withPrivateSettings()`. - DataStore API: `get`, `set`, `setMany`, `getMany`, `update`, `del`, `delMany`, `clear`, `keys`, `values`, `entries`, `createStore` (custom namespace). -- Namespace keys with plugin name: `` await DataStore.get(`MyPlugin_key`) `` or `createStore("MyPlugin", "store")`. +- Namespace keys with plugin name: ``await DataStore.get(`MyPlugin_key`)`` or `createStore("MyPlugin", "store")`. - Never store tokens, message content, or other user data in keys readable by other plugins. ## Network @@ -315,57 +316,57 @@ replace: "$self.exampleWrap({ children: $& })" Pick one per event. Never two for the same thing. -| Need | Use | -|---|---| -| Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` | -| Background happened, user may act later | `showNotification({ title, body })` from `@api/Notifications` | -| Urgent inline top-bar | `Notices.showNotice(msg, btnText, cb)` from `@api/Notices` (sparingly — blocks UI) | -| Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` | +| Need | Use | +| --------------------------------------- | ---------------------------------------------------------------------------------- | +| Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` | +| Background happened, user may act later | `showNotification({ title, body })` from `@api/Notifications` | +| Urgent inline top-bar | `Notices.showNotice(msg, btnText, cb)` from `@api/Notices` (sparingly — blocks UI) | +| Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` | ## Use what exists Search before writing. Common needs: -| Need | Use | -|---|---| -| Sleep | `sleep(ms)` from `@utils/misc` | -| Debounce / throttle | `lodash.debounce` / `lodash.throttle` | -| Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` | -| Markdown parse | `Parser.parse(text)` | -| Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` | -| URL parse | `parseUrl` from `@utils/misc` | -| Regex escape | `escapeRegExp` from `@utils/text` | -| Join lists | `humanFriendlyJoin([...])` from `@utils/text` | -| Class concat | `classes(a, b, ...)` from `@utils/misc` | -| Margins | `Margins.top8` etc, not inline `style` | -| Deep clone | `lodash.cloneDeep` | -| Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` | -| Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` | -| Copy clipboard | `copyWithToast` from `@utils/discord` | -| Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` | -| Open user profile | `openUserProfile(id)` | -| Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` | +| Need | Use | +| ------------------- | ------------------------------------------------------------------ | +| Sleep | `sleep(ms)` from `@utils/misc` | +| Debounce / throttle | `lodash.debounce` / `lodash.throttle` | +| Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` | +| Markdown parse | `Parser.parse(text)` | +| Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` | +| URL parse | `parseUrl` from `@utils/misc` | +| Regex escape | `escapeRegExp` from `@utils/text` | +| Join lists | `humanFriendlyJoin([...])` from `@utils/text` | +| Class concat | `classes(a, b, ...)` from `@utils/misc` | +| Margins | `Margins.top8` etc, not inline `style` | +| Deep clone | `lodash.cloneDeep` | +| Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` | +| Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` | +| Copy clipboard | `copyWithToast` from `@utils/discord` | +| Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` | +| Open user profile | `openUserProfile(id)` | +| Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` | ## Discord components Reuse from `@webpack/common`. Don't recreate. -| Need | Component | -|---|---| -| Button | `Button` | -| Text input | `TextInput` | -| Multiline | `TextArea` | -| Dropdown | `Select` / `SearchableSelect` | -| Checkbox | `Checkbox` | -| Slider | `Slider` | -| Tooltip | `Tooltip` (function-child API) | -| Context menu items | `Menu.*` | -| Modal | `openModal(p => ...)` | -| Scroller | `ScrollerThin` / `ScrollerAuto` | -| User pill | `UserSummaryItem` | -| Avatar | `Avatar` | -| Timestamp | `Timestamp` | -| External link | `MaskedLink` | +| Need | Component | +| ------------------ | --------------------------------------------------- | +| Button | `Button` | +| Text input | `TextInput` | +| Multiline | `TextArea` | +| Dropdown | `Select` / `SearchableSelect` | +| Checkbox | `Checkbox` | +| Slider | `Slider` | +| Tooltip | `Tooltip` (function-child API) | +| Context menu items | `Menu.*` | +| Modal | `openModal(p => ...)` | +| Scroller | `ScrollerThin` / `ScrollerAuto` | +| User pill | `UserSummaryItem` | +| Avatar | `Avatar` | +| Timestamp | `Timestamp` | +| External link | `MaskedLink` | ## Never hardcode @@ -398,29 +399,31 @@ Reuse from `@webpack/common`. Don't recreate. Replaced at build time. Reference only in plugin TS, never literally inside `replace`. -| Constant | Meaning | -|---|---| -| `IS_WEB` | Web build (extension/userscript/standalone web) | -| `IS_EXTENSION` | Browser extension | -| `IS_USERSCRIPT` | Userscript | -| `IS_STANDALONE` | Standalone (no updater) | -| `IS_UPDATER_DISABLED` | Updater off | -| `IS_DEV` | Dev build (auto-diff, extra logs) | -| `IS_REPORTER` | Auto-reporter | -| `IS_COMPANION_TEST` | Companion test | -| `IS_ANTI_CRASH_TEST` | Anti-crash test | -| `IS_DISCORD_DESKTOP` | Discord desktop injection | -| `IS_VESKTOP` | Vesktop | -| `IS_EQUIBOP` | Equibop | -| `VERSION` | Equicord version string | -| `BUILD_TIMESTAMP` | Build epoch ms | +| Constant | Meaning | +| --------------------- | ----------------------------------------------- | +| `IS_WEB` | Web build (extension/userscript/standalone web) | +| `IS_EXTENSION` | Browser extension | +| `IS_USERSCRIPT` | Userscript | +| `IS_STANDALONE` | Standalone (no updater) | +| `IS_UPDATER_DISABLED` | Updater off | +| `IS_DEV` | Dev build (auto-diff, extra logs) | +| `IS_REPORTER` | Auto-reporter | +| `IS_COMPANION_TEST` | Companion test | +| `IS_ANTI_CRASH_TEST` | Anti-crash test | +| `IS_DISCORD_DESKTOP` | Discord desktop injection | +| `IS_VESKTOP` | Vesktop | +| `IS_EQUIBOP` | Equibop | +| `VERSION` | Equicord version string | +| `BUILD_TIMESTAMP` | Build epoch ms | ## Reference ### `@vencord/discord-types` + `Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message`, `Command`, `FluxEvents`, store types. ### `@api/*` + `Settings`, `Commands`, `DataStore`, `ContextMenu`, `Badges`, `Notices`, `Notifications`, `MessageEvents`, `MessageUpdater`, `MessageAccessories`, `MessageDecorations`, `MessagePopover`, `MemberListDecorators`, `ServerList`, `ChatButtons`, `HeaderBar`, `UserArea`, `NicknameIcons`, `ProfileCollections`, `ProfileSections`, `AudioPlayer`, `UserSettings`, `UserSettingDefinitions`, `Styles`, `PluginManager`, `SettingsSync`. `@api/Commands` re-exports `sendBotMessage`, `findOption`, `ApplicationCommandInputType`, `ApplicationCommandOptionType`, `ApplicationCommandType`. @@ -430,6 +433,7 @@ Replaced at build time. Reference only in plugin TS, never literally inside `rep Deprecated: `@api/Themes`. `classNameFactory` re-export from `@api/Styles`. ### `@utils/*` + - `Logger`. `new Logger(name)`. - `css`. `classNameFactory`, `classNameToSelector`. - `misc`. `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity`, `interpolateIfDefined`, `tryOrElse`, `isPluginDev`, `removeFromArray`. @@ -447,6 +451,7 @@ Deprecated: `@api/Themes`. `classNameFactory` re-export from `@api/Styles`. - `types`. `OptionType`, `StartAt`, `ReporterTestable`, `definePlugin`. ### `@components/*` + `Badge`, `BaseText`, `Button`, `Card`, `CheckedTextInput`, `CodeBlock`, `Divider`, `ErrorBoundary`, `ErrorCard`, `Flex`, `FormSwitch`, `Grid`, `Heading`, `Heart`, `Icons`, `Link`, `Notice`, `Paragraph`, `Span`, `Switch`, `margins` (`Margins`), `settings` subtree. `ErrorBoundary.wrap(Component, { noop?, fallback?, onError?, message? })`. @@ -470,9 +475,11 @@ Actions. `MessageActions`, `ChannelActionCreators`, `UserProfileActions`, `Invit Menu. `Menu`, `ContextMenuApi` (`openContextMenu`, `closeContextMenu`, `openContextMenuLazy`). ### Lazy webpack imports + From `@webpack`. `findByPropsLazy`, `findByCodeLazy`, `findComponentLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`, `findStoreLazy`, `findCssClassesLazy`, `findLazy`, `proxyLazyWebpack`, `LazyComponentWebpack`, `mapMangledModuleLazy`, `extractAndLoadChunksLazy`, `waitFor`. `waitForStore` / `waitForComponent` live in `@webpack/common/internal.tsx`. Plugins don't call them directly. ### `IconUtils` + `getUserAvatarURL(user, canAnimate?, size?, format?)`, `getUserBannerURL({ id, banner, canAnimate?, size })`, `getGuildIconURL({ id, icon, size?, canAnimate? })`, `getGuildBannerURL(guild, canAnimate?)`, `getGuildSplashURL`, `getGuildDiscoverySplashURL`, `getGuildHomeHeaderURL`, `getChannelIconURL({ id, icon })`, `getResourceChannelIconURL`, `getEmojiURL({ id, animated, size })`, `getApplicationIconURL`, `getGameAssetURL`, `getVideoFilterAssetURL`, `getAvatarDecorationURL`, `getGuildMemberAvatarURL`, `getGuildMemberAvatarURLSimple`, `getGuildMemberBannerURL`, `getDefaultAvatarURL(id)`, `hasAnimatedGuildIcon`, `isAnimatedIconHash`. diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index b85a54079..a528d6b7b 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -105,7 +105,6 @@ export function CopyIdIcon(props: IconProps) { ); } - /** * Discord's open external icon, as seen in the user profile connections */ diff --git a/src/equicordplugins/betterForwards/README.md b/src/equicordplugins/betterForwards/README.md new file mode 100644 index 000000000..d38a56f96 --- /dev/null +++ b/src/equicordplugins/betterForwards/README.md @@ -0,0 +1,13 @@ +# Better Forwards + +# Dont Follow Forwards + +Don't follow forwarded messages after sending them. + +## Before + +![before](https://github.com/user-attachments/assets/13187c7c-37ea-41cc-bfbc-98c9f517c69b) + +## After + +![after](https://github.com/user-attachments/assets/1b3972d9-fa8e-47c5-a39f-94f4963d94fa) diff --git a/src/equicordplugins/forwardAnywhere/index.ts b/src/equicordplugins/betterForwards/index.ts similarity index 55% rename from src/equicordplugins/forwardAnywhere/index.ts rename to src/equicordplugins/betterForwards/index.ts index 0404c4acf..e6f407060 100644 --- a/src/equicordplugins/forwardAnywhere/index.ts +++ b/src/equicordplugins/betterForwards/index.ts @@ -4,11 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { definePluginSettings } from "@api/Settings"; +import { definePluginSettings, migratePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { sendMessage } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import { Channel, Message } from "@vencord/discord-types"; +import { MouseEvent } from "react"; + +let ignore = false; // Taken From Signature :) const settings = definePluginSettings({ @@ -19,14 +22,22 @@ const settings = definePluginSettings({ { label: ">", value: ">", default: true }, { label: "-#", value: "-#" } ] + }, + dontFollowForwards: { + description: "After forwarding a single message, don't jump to it. Hold shift to ignore this behavior", + displayName: "Don't Follow Forwards", + type: OptionType.BOOLEAN, + default: false, + restartNeeded: true, } }); +migratePluginSettings("BetterForwards", "ForwardAnywhere"); export default definePlugin({ - name: "ForwardAnywhere", - description: "If a forward fails send it as a normal message also allows nsfw forwards", + name: "BetterForwards", + description: "If a forward fails, send it as a normal message. Also allows nsfw forwards. See settings for various other improvements to forwarding.", tags: ["Chat", "Utility"], - authors: [Devs.thororen], + authors: [Devs.thororen, Devs.sadan], settings, patches: [ { @@ -43,6 +54,22 @@ export default definePlugin({ replace: "if ($1) return $self.sendForward($1,$2);", } }, + { + find: "#{intl::MESSAGE_FORWARD_MESSAGE_PLACEHOLDER}", + predicate: () => settings.store.dontFollowForwards, + replacement: [ + { + match: /(?<=transitionToDestination:)(1===\i\.length)(?=,|\})/, + replace: "$self.shouldTransition($1)" + }, + { + // there are two useCallbacks with clearDraft in this module + // we need to anchor to the one that is used as an onClick handler + match: /((\i)=\i\.useCallback\(\()(\)=>\{)(null!=\i&&\i\.\i\.clearDraft)(?=.{1500,2000}onClick:\2)/, + replace: (_, beforeParen, _1, beforeBody, body) => `${beforeParen}vencordArg1${beforeBody}$self.setShift(vencordArg1);${body}` + } + ] + } ], sendForward(channels: Channel[], message: Message) { @@ -60,5 +87,13 @@ export default definePlugin({ }); } }); + }, + + shouldTransition(origCond: boolean): boolean { + return ignore ? origCond : false; + }, + + setShift(event: MouseEvent | undefined) { + ignore = !!event?.shiftKey; } }); diff --git a/src/equicordplugins/fileUpload/settings.tsx b/src/equicordplugins/fileUpload/settings.tsx index 0b211adde..f59247958 100644 --- a/src/equicordplugins/fileUpload/settings.tsx +++ b/src/equicordplugins/fileUpload/settings.tsx @@ -383,7 +383,7 @@ function SettingTextInput(props: { const { description, name, onChange, placeholder, value } = props; return ( - + +
{children}
@@ -418,7 +418,7 @@ function SettingSwitch(props: { const { checked, description, name, onChange } = props; return ( - + ); @@ -469,7 +469,7 @@ function FallbackOrderSettings() { }; return ( - +
{order.map((service, index) => (
- + { @@ -636,7 +636,7 @@ export function SettingsComponent() { onChange={v => (store as { encryptingHostKey?: string; }).encryptingHostKey = v} placeholder="Your Encrypting.host API key" /> - +