├── .github └── workflows │ ├── ci.yml │ ├── discord-activity-starter.yml │ ├── nested-messages.yml │ ├── sdk-playground-production.yml │ └── sdk-playground.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── biome.json ├── discord-activity-starter ├── .gitignore ├── README.md ├── package.json └── packages │ ├── client │ ├── .gitignore │ ├── favicon.svg │ ├── index.html │ ├── package.json │ ├── src │ │ ├── discordSdk.ts │ │ ├── main.ts │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts │ └── server │ ├── environment.d.ts │ ├── package.json │ ├── src │ ├── app.ts │ ├── shared │ │ └── hello.ts │ └── utils.ts │ └── tsconfig.json ├── nested-messages ├── .example.env ├── .gitignore ├── README.md ├── client │ ├── index.html │ ├── index.ts │ ├── nested │ │ ├── index.html │ │ └── index.ts │ └── utils │ │ ├── MessageInterface.ts │ │ ├── initializeSdk.ts │ │ └── types.ts ├── package.json ├── scripts │ └── build.js ├── server │ └── index.js ├── tsconfig.json └── types.d.ts ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json └── sdk-playground ├── .gitignore ├── README.md ├── example.env ├── package.json └── packages ├── client ├── .gitignore ├── assets │ ├── baby-brick.jpeg │ └── brick-pug-life.gif ├── index.html ├── package.json ├── src │ ├── App.css │ ├── App.tsx │ ├── AppStyles.tsx │ ├── Constants.ts │ ├── DiscordAPI.ts │ ├── actions │ │ └── authActions.ts │ ├── components │ │ ├── AuthProvider.tsx │ │ ├── DesignSystemProvider.tsx │ │ ├── LoadingScreen.tsx │ │ ├── ReactJsonView.tsx │ │ ├── Scrollable.tsx │ │ └── Search.tsx │ ├── declare.d.ts │ ├── discordSdk.tsx │ ├── favicon.svg │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── main.tsx │ ├── pages │ │ ├── ActivityChannel.tsx │ │ ├── ActivityParticipants.tsx │ │ ├── AvatarAndName.tsx │ │ ├── CloseActivity.tsx │ │ ├── CurrentGuild.tsx │ │ ├── CurrentGuildMember.tsx │ │ ├── CurrentUser.tsx │ │ ├── EncourageHardwareAcceleration.tsx │ │ ├── GamepadTester.tsx │ │ ├── GetActivityInstance.tsx │ │ ├── GetChannel.tsx │ │ ├── GetChannelPermissions.tsx │ │ ├── GetEntitlements.tsx │ │ ├── GetInstanceConnectedParticipants.tsx │ │ ├── GetPlatformBehaviors.tsx │ │ ├── GetSkus.tsx │ │ ├── Guilds.tsx │ │ ├── Home.tsx │ │ ├── InAppPurchase.tsx │ │ ├── InitiateImageUpload.tsx │ │ ├── LayoutMode.tsx │ │ ├── OpenExternalLink.tsx │ │ ├── OpenInviteDialog.tsx │ │ ├── OpenShareMomentDialog.tsx │ │ ├── OrientationLockState.tsx │ │ ├── OrientationUpdates.tsx │ │ ├── PlatformBehaviors.tsx │ │ ├── QuickLink.tsx │ │ ├── SafeAreas.tsx │ │ ├── SetActivity.tsx │ │ ├── ShareLink.tsx │ │ ├── ThermalStates.tsx │ │ ├── UserSettingsGetLocale.tsx │ │ ├── VisibilityListener.tsx │ │ ├── VoiceState.tsx │ │ └── WindowSizeTracker.tsx │ ├── reportWebVitals.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── stores │ │ └── authStore.ts │ ├── styled │ │ └── index.ts │ ├── types.tsx │ ├── utils │ │ ├── getUserAvatarUrl.tsx │ │ └── getUserDisplayName.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts └── server ├── .gitignore ├── handle-wrangler-secrets.sh ├── package.json ├── src ├── handleApiRequest.ts ├── handleErrors.ts ├── handlers │ ├── getActivityInstanceHandler.ts │ ├── iapHandler.ts │ └── tokenHandler.ts ├── index.ts ├── types.ts └── utils.ts ├── test └── index.test.ts ├── tsconfig.json └── wrangler.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test-ci: 11 | name: test-ci 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: pnpm/action-setup@v4 17 | with: 18 | version: 9.0.6 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | - run: pnpm install 24 | - run: pnpm lint 25 | -------------------------------------------------------------------------------- /.github/workflows/discord-activity-starter.yml: -------------------------------------------------------------------------------- 1 | name: discord-activity-starter 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'discord-activity-starter/packages/**' 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - 'discord-activity-starter/packages/**' 12 | 13 | jobs: 14 | tsc: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: 9.0.6 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: pnpm 25 | - run: pnpm install 26 | - run: npm run build 27 | working-directory: discord-activity-starter/packages/server 28 | - run: npm run build 29 | working-directory: discord-activity-starter/packages/client 30 | -------------------------------------------------------------------------------- /.github/workflows/nested-messages.yml: -------------------------------------------------------------------------------- 1 | name: nested-messages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'nested-messages/packages/**' 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - 'nested-messages/packages/**' 12 | 13 | jobs: 14 | tsc: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: 9.0.6 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: pnpm 25 | - run: pnpm install 26 | - run: npm run build 27 | working-directory: nested-messages/ 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/sdk-playground-production.yml: -------------------------------------------------------------------------------- 1 | name: sdk playground production deploy 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | build: 8 | environment: production 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | with: 14 | version: latest 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: pnpm 19 | - run: pnpm install 20 | - name: create env file 21 | run: | 22 | cd sdk-playground 23 | echo "${{ secrets.ENV_FILE }}" > .env.production 24 | - name: build sdk-playground client 25 | run: | 26 | cd sdk-playground/packages/client 27 | pnpm build 28 | - name: deploy client 29 | uses: cloudflare/wrangler-action@v3.14.0 30 | with: 31 | packageManager: pnpm 32 | apiToken: ${{ secrets.CF_API_TOKEN }} 33 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 34 | workingDirectory: sdk-playground/packages/client 35 | command: pages deploy --project-name=sdk-playground dist 36 | wranglerVersion: '3.52.0' 37 | - name: deploy server 38 | uses: cloudflare/wrangler-action@v3.14.0 39 | with: 40 | packageManager: pnpm 41 | apiToken: ${{ secrets.CF_API_TOKEN }} 42 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 43 | preCommands: ./handle-wrangler-secrets.sh production remote 44 | workingDirectory: sdk-playground/packages/server 45 | command: publish src/index.ts --env production 46 | wranglerVersion: '3.52.0' 47 | -------------------------------------------------------------------------------- /.github/workflows/sdk-playground.yml: -------------------------------------------------------------------------------- 1 | name: sdk-playground 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'sdk-playground/packages/**' 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - 'sdk-playground/packages/**' 12 | 13 | jobs: 14 | tsc: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: 9.0.6 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: pnpm 25 | - run: pnpm install 26 | - run: npm run build 27 | working-directory: sdk-playground/packages/server 28 | - run: npm run build 29 | working-directory: sdk-playground/packages/client 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | output 3 | *.log 4 | .DS_Store 5 | tmp 6 | *.tsbuildinfo 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > See the [README](https://github.com/discord/embedded-app-sdk-examples/blob/main/README.md) for licensing and legal information 4 | 5 | Thank you for your interest in contributing to our examples! Before you create that pull request, here are some examples of wanted and unwanted changes to the repository. 6 | 7 | ## Types of Changes 8 | 9 | ### Wanted Changes 10 | 11 | 1. Bug fixes that address broken behavior or crashes in the examples. 12 | 1. Links to new externally maintained examples in the Community Curated Examples section of the README. 13 | 1. Fixes to incorrect statements or inaccuracies within the documentation. 14 | 1. Fixing of spelling and grammatical errors in the documentation. 15 | 16 | ### Unwanted Changes 17 | 18 | 1. New example codebases added directly to the repository. 19 | 1. Whitespace or formatting changes. 20 | 1. Subjective wording changes. 21 | 1. Additions that document unreleased product functionality. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Discord Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Embedded App SDK Examples 2 | 3 |

4 | Discord Embedded App SDK 5 |

6 | 7 | This repository contains a collection of examples for the [Discord Embedded App SDK](https://github.com/discord/embedded-app-sdk). To get started, visit [discord.dev](https://discord.dev). 8 | 9 | ## Discord Supported Examples 10 | 11 | ### [Discord Activity Starter](discord-activity-starter) 12 | 13 | This starter app will get you up and running quickly with the basic skeleton of an Embedded App using Node.js, React, and Vite. 14 | 15 | ### [SDK Playground](sdk-playground) 16 | 17 | This example implements the SDK commands and events available within the application's iframe while building your app. 18 | 19 | ### [Nested Messages](nested-messages) 20 | 21 | This example implements an Embedded App using a nested framework like a game engine. When using a game engine, you need to send messages between a parent iframe and the nested framework. 22 | 23 | ## Community Curated Examples 24 | 25 | These examples are maintained by members of the Discord Developer Community. 26 | 27 | | | | 28 | |-|-| 29 | | [Colyseus](https://github.com/colyseus/discord-embedded-app-sdk) | This example uses Node.js, React, and [Colyseus](https://colyseus.io/), a multiplayer JS framework, to demonstrate state management in a multiplayer experience. | 30 | | [Robo.js](https://robojs.dev/templates/overview#discord-activities) | A collection of examples using the **[Robo.js](https://robojs.dev)** framework, a framework for building Discord Activities. Includes examples for a variety of use cases, such as multiplayer, AI, proxying, and more. | 31 | | [Wonderland Engine](https://github.com/WonderlandEngine/discord-activity-example) | This example uses [Wonderland Engine](https://wonderlandengine.com) and Node.js to demonstrate performant 3D rendering inside an Embedded App. | 32 | | [Phaser](https://github.com/phaserjs/discord-template) | This example uses [Phaser](https://phaser.io), a JavaScript game framework, along with HTML, CSS, and vanilla JavaScript to demonstrate running a Phaser game as a Discord Activity. Additionally, you can explore the [multiplayer template](https://github.com/phaserjs/discord-multiplayer-template), which integrates Phaser and [Colyseus](https://colyseus.io/) to showcase a multiplayer Phaser game on Discord. | 33 | | [Cloudflare Starter](https://github.com/oskvr37/discord-activity-cloudflare-starter) | Deploy your embedded app easily with this Cloudflare example, using Vite for the frontend and Hono for the backend. | 34 | 35 | ## Discord Developer Terms of Service & Developer Policy 36 | 37 | Please note that while these examples are licensed under the MIT License, the [Discord Developer Terms of Service](https://discord.com/developers/docs/policies-and-agreements/developer-terms-of-service) and [Discord Developer Policy](https://discord.com/developers/docs/policies-and-agreements/developer-policy) otherwise still apply to you and the applications you develop utilizing these examples. 38 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "files": { 4 | "include": ["./**/*.js", "./**/*.ts"], 5 | "ignore": [ 6 | "**/node_modules", 7 | "**/dist", 8 | "**/build", 9 | "**/coverage", 10 | "**/lib", 11 | "**/.wrangler" 12 | ] 13 | }, 14 | "organizeImports": { 15 | "enabled": true 16 | }, 17 | "linter": { 18 | "enabled": true, 19 | "rules": { 20 | "recommended": true 21 | } 22 | }, 23 | "javascript": { 24 | "formatter": { 25 | "quoteStyle": "single" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /discord-activity-starter/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.pem 3 | *.log 4 | .DS_Store 5 | build 6 | dist 7 | .env 8 | -------------------------------------------------------------------------------- /discord-activity-starter/README.md: -------------------------------------------------------------------------------- 1 | # Discord Embedded App Starter 2 | 3 | This repo is a minimal starter-project. Getting an embedded app running in Discord can be complex. The goal of this example is to get you up-and-running as quickly as possible, while making it easy to swap in pieces to fit your embedded app's client and server needs. 4 | 5 | ## Client architecture 6 | 7 | The client (aka front-end) is using [ViteJS](https://vitejs.dev/)'s Vanilla Typescript starter project. Vite has great starter projects in [many common javascript frameworks](https://vitejs.dev/guide/#trying-vite-online). All of these projects use the same config setup, which means that if you prefer React, Svelte, etc... you can swap frameworks and still get the following: 8 | 9 | - Fast typescript bundling with hot-module-reloading 10 | - Identical configuration API 11 | - Identical environment variable API 12 | 13 | Note: ViteJS is not required to use Discord's `embedded-app-sdk`. ViteJS is a meta-client-framework we are using to make it easy to help you get running quickly, but the core concepts of developing an embedded application are the same, regardless of how you are consuming `embedded-app-sdk`. 14 | 15 | ## Server architecture 16 | 17 | The server (aka back-end) is using Express with typescript. Any file in the server project can be imported by the client, in case you need to share business logic. 18 | 19 | ## Setting up your Discord Application 20 | 21 | Before we write any code, lets follow the instructions [here](https://discord.com/developers/docs/activities/building-an-activity#step-1-creating-a-new-app) to make sure your Discord application is set up correctly. 22 | 23 | ## Setting up your environment variables 24 | 25 | In this directory (`/examples/discord-activity-starter`) we need to create a `.env` file with the OAuth2 variables, as described [here](https://discord.com/developers/docs/activities/building-an-activity#find-your-oauth2-credentials). 26 | 27 | ```env 28 | VITE_CLIENT_ID=123456789012345678 29 | CLIENT_SECRET=abcdefghijklmnopqrstuvwxyzabcdef 30 | ``` 31 | 32 | ### Adding a new environment variable 33 | 34 | In order to add new environment variables, you will need to do the following: 35 | 36 | 1. Add the environment key and value to `.env` 37 | 2. Add the key to [/examples/discord-activity-starter/packages/client/src/vite-env.d.ts](/examples/discord-activity-starter/packages/client/src/vite-env.d.ts) 38 | 3. Add the key to [/examples/discord-activity-starter/packages/server/environment.d.ts](/examples/discord-activity-starter/packages/server/environment.d.ts) 39 | 40 | This will ensure that you have type safety when consuming your environment variables 41 | 42 | ## Running your app locally 43 | 44 | As described [here](https://discord.com/developers/docs/activities/building-an-activity#step-4-running-your-app-locally-in-discord), we encourage using a tunnel solution such as [cloudflared](https://github.com/cloudflare/cloudflared#installing-cloudflared) for local development. 45 | To run your app locally, run the following from this directory (/examples/discord-activity-starter) 46 | 47 | ``` 48 | pnpm install # only need to run this the first time 49 | pnpm dev 50 | pnpm tunnel # from another terminal 51 | ``` 52 | 53 | Be sure to complete all the steps listed [here](https://discord.com/developers/docs/activities/building-an-activity) to ensure your development setup is working as expected. 54 | -------------------------------------------------------------------------------- /discord-activity-starter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-activity-starter", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "A minimal starter project using embedded-app-sdk", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "pnpm run --filter \"./packages/**\" --parallel dev", 9 | "tunnel": "cloudflared tunnel --url http://localhost:3000" 10 | }, 11 | "author": "Discord", 12 | "license": "MIT", 13 | "dependencies": { 14 | "express": "^4.19.2" 15 | }, 16 | "devDependencies": { 17 | "@types/express": "^5.0.0", 18 | "@types/node": "^22.0.0", 19 | "nodemon": "^3.1.0", 20 | "npm-run-all2": "^7.0.0", 21 | "rimraf": "^6.0.0", 22 | "ts-node": "^10.9.1", 23 | "typescript": "~5.8.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Discord Embedded App Starter 8 | 9 | 10 |

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@discord/embedded-app-sdk": "^2.0.0" 12 | }, 13 | "devDependencies": { 14 | "vite": "^5.2.9" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/src/discordSdk.ts: -------------------------------------------------------------------------------- 1 | import { DiscordSDK } from '@discord/embedded-app-sdk'; 2 | 3 | export const discordSdk = new DiscordSDK(import.meta.env.VITE_CLIENT_ID); 4 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/src/main.ts: -------------------------------------------------------------------------------- 1 | import type { CommandResponse } from '@discord/embedded-app-sdk'; 2 | import './style.css'; 3 | import { discordSdk } from './discordSdk'; 4 | 5 | type Auth = CommandResponse<'authenticate'>; 6 | let auth: Auth; 7 | 8 | // Once setupDiscordSdk is complete, we can assert that "auth" is initialized 9 | setupDiscordSdk().then(() => { 10 | appendVoiceChannelName(); 11 | appendGuildAvatar(); 12 | }); 13 | 14 | async function setupDiscordSdk() { 15 | await discordSdk.ready(); 16 | 17 | // Authorize with Discord Client 18 | const { code } = await discordSdk.commands.authorize({ 19 | client_id: import.meta.env.VITE_CLIENT_ID, 20 | response_type: 'code', 21 | state: '', 22 | prompt: 'none', 23 | // More info on scopes here: https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes 24 | scope: [ 25 | // Activities will launch through app commands and interactions of user-installable apps. 26 | // https://discord.com/developers/docs/tutorials/developing-a-user-installable-app#configuring-default-install-settings-adding-default-install-settings 27 | 'applications.commands', 28 | 29 | // "applications.builds.upload", 30 | // "applications.builds.read", 31 | // "applications.store.update", 32 | // "applications.entitlements", 33 | // "bot", 34 | 'identify', 35 | // "connections", 36 | // "email", 37 | // "gdm.join", 38 | 'guilds', 39 | // "guilds.join", 40 | 'guilds.members.read', 41 | // "messages.read", 42 | // "relationships.read", 43 | // 'rpc.activities.write', 44 | // "rpc.notifications.read", 45 | // "rpc.voice.write", 46 | 'rpc.voice.read', 47 | // "webhook.incoming", 48 | ], 49 | }); 50 | 51 | // Retrieve an access_token from your activity's server 52 | // /.proxy/ is prepended here in compliance with CSP 53 | // see https://discord.com/developers/docs/activities/development-guides#construct-a-full-url 54 | const response = await fetch('/.proxy/api/token', { 55 | method: 'POST', 56 | headers: { 57 | 'Content-Type': 'application/json', 58 | }, 59 | body: JSON.stringify({ 60 | code, 61 | }), 62 | }); 63 | const { access_token } = await response.json(); 64 | 65 | // Authenticate with Discord client (using the access_token) 66 | auth = await discordSdk.commands.authenticate({ 67 | access_token, 68 | }); 69 | 70 | if (auth == null) { 71 | throw new Error('Authenticate command failed'); 72 | } 73 | } 74 | 75 | /** 76 | * This function fetches the current voice channel over RPC. It then creates a 77 | * text element that displays the voice channel's name 78 | */ 79 | async function appendVoiceChannelName() { 80 | const app = document.querySelector('#app'); 81 | if (!app) { 82 | throw new Error('Could not find #app element'); 83 | } 84 | 85 | let activityChannelName = 'Unknown'; 86 | 87 | // Requesting the channel in GDMs (when the guild ID is null) requires 88 | // the dm_channels.read scope which requires Discord approval. 89 | if (discordSdk.channelId != null && discordSdk.guildId != null) { 90 | // Over RPC collect info about the channel 91 | const channel = await discordSdk.commands.getChannel({ 92 | channel_id: discordSdk.channelId, 93 | }); 94 | if (channel.name != null) { 95 | activityChannelName = channel.name; 96 | } 97 | } 98 | 99 | // Update the UI with the name of the current voice channel 100 | const textTagString = `Activity Channel: "${activityChannelName}"`; 101 | const textTag = document.createElement('p'); 102 | textTag.textContent = textTagString; 103 | app.appendChild(textTag); 104 | } 105 | 106 | /** 107 | * This function utilizes RPC and HTTP apis, in order show the current guild's avatar 108 | * Here are the steps: 109 | * 1. From RPC fetch the currently selected voice channel, which contains the voice channel's guild id 110 | * 2. From the HTTP API fetch a list of all of the user's guilds 111 | * 3. Find the current guild's info, including its "icon" 112 | * 4. Append to the UI an img tag with the related information 113 | */ 114 | async function appendGuildAvatar() { 115 | const app = document.querySelector('#app'); 116 | if (!app) { 117 | throw new Error('Could not find #app element'); 118 | } 119 | 120 | // 1. From the HTTP API fetch a list of all of the user's guilds 121 | const guilds: Array<{ id: string; icon: string }> = await fetch( 122 | 'https://discord.com/api/users/@me/guilds', 123 | { 124 | headers: { 125 | // NOTE: we're using the access_token provided by the "authenticate" command 126 | Authorization: `Bearer ${auth.access_token}`, 127 | 'Content-Type': 'application/json', 128 | }, 129 | }, 130 | ).then((reply) => reply.json()); 131 | 132 | // 2. Find the current guild's info, including it's "icon" 133 | const currentGuild = guilds.find((g) => g.id === discordSdk.guildId); 134 | 135 | // 3. Append to the UI an img tag with the related information 136 | if (currentGuild != null) { 137 | const guildImg = document.createElement('img'); 138 | guildImg.setAttribute( 139 | 'src', 140 | // More info on image formatting here: https://discord.com/developers/docs/reference#image-formatting 141 | `https://cdn.discordapp.com/icons/${currentGuild.id}/${currentGuild.icon}.webp?size=128`, 142 | ); 143 | guildImg.setAttribute('width', '128px'); 144 | guildImg.setAttribute('height', '128px'); 145 | guildImg.setAttribute('style', 'border-radius: 50%;'); 146 | app.appendChild(guildImg); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/src/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | text-align: center; 6 | color: black; 7 | background: white; 8 | } 9 | 10 | html, 11 | body, 12 | #app { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | * { 18 | margin: 0; 19 | padding: 0; 20 | box-sizing: border-box; 21 | } 22 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_CLIENT_ID: string; 5 | // add env variables here 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Bundler", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | envDir: '../../', 6 | server: { 7 | port: 3000, 8 | proxy: { 9 | '/api': { 10 | target: 'http://localhost:3001', 11 | changeOrigin: true, 12 | secure: false, 13 | ws: true, 14 | }, 15 | }, 16 | hmr: { 17 | clientPort: 443, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | VITE_CLIENT_ID: string; 5 | CLIENT_SECRET: string; 6 | NODE_ENV: 'development' | 'production'; 7 | PORT?: string; 8 | PWD: string; 9 | } 10 | } 11 | } 12 | 13 | export type {}; 14 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.1.0", 4 | "description": "The app server", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm-run-all build start:prod", 8 | "start:prod": "NODE_ENV=production node ./dist/app.js", 9 | "dev": "nodemon --watch src -e ts,ejs --exec $npm_execpath start", 10 | "build": "npm-run-all build:clean build:tsc", 11 | "build:clean": "rimraf dist/*", 12 | "build:tsc": "tsc", 13 | "debug:start": "npm-run-all build debug:start:prod", 14 | "debug:start:prod": "node --nolazy --inspect-brk=9229 ./dist/app.js" 15 | }, 16 | "author": "Discord", 17 | "license": "MIT", 18 | "dependencies": { 19 | "dotenv": "^16.0.1", 20 | "nodemon": "^3.1.0" 21 | }, 22 | "devDependencies": { 23 | "npm-run-all2": "^7.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/src/app.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import dotenv from 'dotenv'; 3 | import express, { 4 | type Application, 5 | type Request, 6 | type Response, 7 | } from 'express'; 8 | import { fetchAndRetry } from './utils'; 9 | dotenv.config({ path: '../../.env' }); 10 | 11 | const app: Application = express(); 12 | const port: number = Number(process.env.PORT) || 3001; 13 | 14 | app.use(express.json()); 15 | 16 | if (process.env.NODE_ENV === 'production') { 17 | const clientBuildPath = path.join(__dirname, '../../client/dist'); 18 | app.use(express.static(clientBuildPath)); 19 | } 20 | 21 | // Fetch token from developer portal and return to the embedded app 22 | app.post('/api/token', async (req: Request, res: Response) => { 23 | const response = await fetchAndRetry('https://discord.com/api/oauth2/token', { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/x-www-form-urlencoded', 27 | }, 28 | body: new URLSearchParams({ 29 | client_id: process.env.VITE_CLIENT_ID, 30 | client_secret: process.env.CLIENT_SECRET, 31 | grant_type: 'authorization_code', 32 | code: req.body.code, 33 | }), 34 | }); 35 | 36 | const { access_token } = (await response.json()) as { 37 | access_token: string; 38 | }; 39 | 40 | res.send({ access_token }); 41 | }); 42 | 43 | app.listen(port, () => { 44 | console.log(`App is listening on port ${port} !`); 45 | }); 46 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/src/shared/hello.ts: -------------------------------------------------------------------------------- 1 | export function hello() { 2 | console.log("hello from the server's shared folder"); 3 | } 4 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | 5 | /** 6 | * This function extends fetch to allow retrying 7 | * If the request returns a 429 error code, it will wait and retry after "retry_after" seconds 8 | */ 9 | export async function fetchAndRetry( 10 | input: RequestInfo, 11 | init?: RequestInit | undefined, 12 | nRetries = 3, 13 | ): Promise { 14 | try { 15 | // Make the request 16 | const response = await fetch(input, init); 17 | 18 | // If there's a 429 error code, retry after retry_after seconds 19 | // https://discord.com/developers/docs/topics/rate-limits#rate-limits 20 | if (response.status === 429 && nRetries > 0) { 21 | const retryAfter = Number(response.headers.get('retry_after')); 22 | if (Number.isNaN(retryAfter)) { 23 | return response; 24 | } 25 | await sleep(retryAfter * 1000); 26 | return await fetchAndRetry(input, init, nRetries - 1); 27 | } 28 | return response; 29 | } catch (ex) { 30 | if (nRetries <= 0) { 31 | throw ex; 32 | } 33 | 34 | // If the request failed, wait one second before trying again 35 | // This could probably be fancier with exponential backoff 36 | await sleep(1000); 37 | return await fetchAndRetry(input, init, nRetries - 1); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /discord-activity-starter/packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true 10 | }, 11 | "include": ["src/**/*.ts", "./environment.d.ts"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /nested-messages/.example.env: -------------------------------------------------------------------------------- 1 | # Copy, then rename this file .env and then put CLIENT_ID, etc... in the .env file 2 | CLIENT_ID=1234567890 3 | CLIENT_SECRET=abcdefghijklm 4 | -------------------------------------------------------------------------------- /nested-messages/.gitignore: -------------------------------------------------------------------------------- 1 | client/**/*.js 2 | .env 3 | -------------------------------------------------------------------------------- /nested-messages/README.md: -------------------------------------------------------------------------------- 1 | # Embedded app with Nested Messages 2 | 3 | The `embedded-app-sdk` is intended for use by a single-page application. We recognize developers may be using frameworks or approaches that do not necessarily "fit into the bucket" of single-page applications, and wanted to provide some suggestions, specifically, we recommend nesting those frameworks inside of your embedded app's top-level single-page application and passing messages as you see fit. The developer recognizes that Discord may not be able to provide support, guidance, or code samples for communication required between your embedded app's top-level single-page applications and any frameworks you use inside of it. 4 | 5 | This example shows how an embedded app with a nested framework, such as a iframe hosting a multi-page-app, an iframe hosting a Unity App, or any other unique framework can be set up to work inside of a Discord embedded app iframe. We will create a parent website to mount the nested framework, hold state, and pass along messages between the Discord Client and the nested framework. This example is not meant to serve as a source-of-truth for how you should implement passing messages, instead it's a minimal example, which you could take inspiration from, or expand upon, based on your embedded app's needs. 6 | 7 | ## How to run 8 | 9 | This embedded app depends on the embedded-app-sdk being built. To build the package, from the root of this repository run the following commands in your terminal. 10 | 11 | ``` 12 | pnpm install 13 | pnpm build 14 | ``` 15 | 16 | ### Set up your .env file 17 | 18 | Copy/rename the [.example.env](/examples/nested-messages/.example.env) file to `.env`. 19 | Fill in `CLIENT_ID` and `CLIENT_SECRET` with the OAuth2 Client ID and Client Secret, as described [here](https://discord.com/developers/docs/activities/building-an-activity#find-your-oauth2-credentials). 20 | 21 | To serve this embedded app locally, from terminal navigate to `/embedded-app-sdk/examples/nested-messages` and run the following: 22 | 23 | ``` 24 | pnpm install 25 | pnpm dev 26 | ``` 27 | 28 | ## Many ways to solve a puzzle 29 | 30 | In this example, the core issue we're trying to solve is how to pass messages (commands, responses, and event subscriptions) between the Discord client and a nested framework inside of your embedded app. This example solves the puzzle by creating a `MessageInterface` class which looks very similar to `embedded-app-sdk`. This is for the following reasons: 31 | 32 | 1. It's also using javascript inside the nested iframe 33 | 2. It needs to solve many similar problems, such as passing events and listening for events that have been subscribed to. 34 | 35 | Depending on your use-case, you may find an alternate solution to `MessageInterface` to be a better fit for your embedded app. 36 | 37 | ## Nested Messages architecture 38 | 39 | ### Routing 40 | 41 | All client code is located in [/client](/examples/nested-messages/client/) and is served via a NodeJS Express server. Each route is provided by an `index.html` file. Each `index.html` file has a corresponding `index.ts` file and a (gitignored) compiled `index.js` file which is consumed by the html file. For example, let's consider the nested "embedded app", which is served at `/nested`. When a user visits this route, they are sent the file [client/nested/index.html](/examples/nested-messages/client/nested/index.html) which imports the compiled javascript from [client/nested/index.ts](/examples/nested-messages/client/nested/index.ts). 42 | 43 | ### Build tooling 44 | 45 | All typescript files inside of the `client` directory can be compiled into javascript files by running `npm run build`. When developing, you can run `npm run dev` which will rebuild whenever a file-change is detected inside of the `client` directory. 46 | 47 | ### Routes 48 | 49 | In this example, we have an embedded app which is nested inside of the parent "embedded app host". The embedded app host's responsibility is to initialize the SDK, listen for commands sent from the nested embedded app, and pass along responses sent by the Discord client. 50 | 51 | We have added a button to the nested embedded app which allows it to call `window.location.reload()` without invalidating the embedded app session. 52 | 53 | ### Nested message management 54 | 55 | See [client/index.ts](/examples/nested-messages/client/index.ts) to learn more about the "how" of how this example supports nested embedded app messages. 56 | -------------------------------------------------------------------------------- /nested-messages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 | 21 | 22 |
Parent iframe
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /nested-messages/client/index.ts: -------------------------------------------------------------------------------- 1 | import type { SetActivityInput } from '@discord/embedded-app-sdk/output/commands/setActivity'; 2 | import { initializeSdk } from './utils/initializeSdk'; 3 | import type { MessageData } from './utils/types'; 4 | 5 | window.addEventListener('DOMContentLoaded', setupParentIframe); 6 | 7 | export async function setupParentIframe() { 8 | const discordSdk = await initializeSdk(); 9 | notifyChildParentIsReady(); 10 | 11 | async function handleMessage({ 12 | data: messageData, 13 | }: MessageEvent) { 14 | // Bail out if messageData is not an "{}" object 15 | if ( 16 | typeof messageData !== 'object' || 17 | Array.isArray(messageData) || 18 | messageData === null 19 | ) { 20 | return; 21 | } 22 | const { nonce, event, command, data, args } = messageData; 23 | 24 | function handleSubscribeEvent(eventData) { 25 | getChildIframe().contentWindow?.postMessage( 26 | { 27 | event, 28 | command: 'DISPATCH', 29 | data: eventData, 30 | }, 31 | '*', 32 | ); 33 | } 34 | 35 | switch (command) { 36 | case 'NOTIFY_CHILD_IFRAME_IS_READY': { 37 | notifyChildParentIsReady(); 38 | break; 39 | } 40 | case 'SUBSCRIBE': { 41 | if (event == null) { 42 | throw new Error('SUBSCRIBE event is undefined'); 43 | } 44 | 45 | discordSdk.subscribe(event, handleSubscribeEvent, args); 46 | break; 47 | } 48 | case 'UNSUBSCRIBE': { 49 | if (event == null) { 50 | throw new Error('UNSUBSCRIBE event is undefined'); 51 | } 52 | discordSdk.unsubscribe(event, handleSubscribeEvent); 53 | break; 54 | } 55 | case 'SET_ACTIVITY': { 56 | const reply = await discordSdk.commands.setActivity( 57 | data as unknown as SetActivityInput, 58 | ); 59 | getChildIframe().contentWindow?.postMessage( 60 | { nonce, event, command, data: reply }, 61 | '*', 62 | ); 63 | break; 64 | } 65 | } 66 | } 67 | 68 | window.addEventListener('message', handleMessage); 69 | } 70 | 71 | function getChildIframe(): HTMLIFrameElement { 72 | const iframe = document.getElementById( 73 | 'child-iframe', 74 | ) as HTMLIFrameElement | null; 75 | if (iframe == null) { 76 | throw new Error('Child iframe not found'); 77 | } 78 | return iframe; 79 | } 80 | 81 | function notifyChildParentIsReady() { 82 | const iframe = getChildIframe(); 83 | iframe.contentWindow?.postMessage( 84 | { 85 | event: 'READY', 86 | command: 'DISPATCH', 87 | }, 88 | '*', 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /nested-messages/client/nested/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Game 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /nested-messages/client/nested/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageInterface } from '../utils/MessageInterface'; 2 | 3 | const messageInterface = new MessageInterface(); 4 | 5 | window.addEventListener('DOMContentLoaded', async () => { 6 | await messageInterface.ready(); 7 | 8 | messageInterface.sendMessage({ 9 | command: 'SET_ACTIVITY', 10 | data: { 11 | activity: { 12 | details: 'Set Activity from nested iframe', 13 | type: 0, 14 | state: 'Playing', 15 | }, 16 | }, 17 | }); 18 | 19 | const parentQueryParams = new URLSearchParams(window.parent.location.search); 20 | const channelId = parentQueryParams.get('channel_id'); 21 | 22 | messageInterface.subscribe( 23 | 'SPEAKING_START', 24 | ({ user_id }) => { 25 | console.log(`"${user_id}" Just started talking`); 26 | }, 27 | { channel_id: channelId }, 28 | ); 29 | 30 | messageInterface.subscribe( 31 | 'SPEAKING_STOP', 32 | ({ user_id }) => { 33 | console.log(`"${user_id}" Just stopped talking`); 34 | }, 35 | { channel_id: channelId }, 36 | ); 37 | 38 | const reloadButton = document.getElementById('reload'); 39 | reloadButton?.addEventListener('click', () => { 40 | console.log('reloading'); 41 | window.location.reload(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /nested-messages/client/utils/MessageInterface.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | import type { MessageData } from './types'; 5 | 6 | export class MessageInterface { 7 | isReady = false; 8 | private pendingMessages: Map< 9 | string, 10 | { 11 | resolve: (response: unknown) => unknown; 12 | reject: (error: unknown) => unknown; 13 | } 14 | > = new Map(); 15 | private eventBus = new EventEmitter(); 16 | 17 | constructor() { 18 | window.addEventListener('message', this.handleMessage); 19 | 20 | this.eventBus.once('READY', () => { 21 | this.isReady = true; 22 | }); 23 | 24 | this.sendMessage({ command: 'NOTIFY_CHILD_IFRAME_IS_READY' }); 25 | } 26 | 27 | ready = async () => { 28 | if (this.isReady) { 29 | return; 30 | } 31 | await new Promise((resolve) => { 32 | this.eventBus.once('READY', resolve); 33 | }); 34 | }; 35 | 36 | subscribe = async ( 37 | event: string, 38 | listener: (...args: unknown[]) => unknown, 39 | subscribeArgs?: Record, 40 | ) => { 41 | const listenerCount = this.eventBus.listenerCount(event); 42 | const emitter = this.eventBus.on(event, listener); 43 | 44 | if (listenerCount === 0) { 45 | await this.sendMessage({ 46 | command: 'SUBSCRIBE', 47 | args: subscribeArgs, 48 | event, 49 | }); 50 | } 51 | 52 | return emitter; 53 | }; 54 | 55 | unsubscribe = async ( 56 | event: string, 57 | listener: (...args: unknown[]) => unknown, 58 | ) => { 59 | if (this.eventBus.listenerCount(event) === 1) { 60 | await this.sendMessage({ 61 | command: 'UNSUBSCRIBE', 62 | event, 63 | }); 64 | } 65 | return this.eventBus.off(event, listener); 66 | }; 67 | 68 | handleMessage = (ev: MessageEvent) => { 69 | const { data: messageData } = ev; 70 | // Bail out if messageData is not an "{}" object 71 | if ( 72 | typeof messageData !== 'object' || 73 | Array.isArray(messageData) || 74 | messageData === null 75 | ) { 76 | return; 77 | } 78 | const { nonce, event, command, data } = messageData; 79 | 80 | if (command === 'DISPATCH') { 81 | if (event == null) { 82 | throw new Error('DISPATCH has no event defined'); 83 | } 84 | this.eventBus.emit(event, data); 85 | return; 86 | } 87 | 88 | if (!nonce) { 89 | console.warn('Message received without a nonce', ev); 90 | return; 91 | } 92 | 93 | this.pendingMessages.get(nonce)?.resolve(messageData); 94 | this.pendingMessages.delete(nonce); 95 | }; 96 | 97 | sendMessage = ({ command, event, data, args }: Record) => { 98 | const nonce = uuidv4(); 99 | window.parent.postMessage( 100 | { 101 | command, 102 | event, 103 | data, 104 | args, 105 | nonce, 106 | }, 107 | document.referrer ? document.referrer : '*', 108 | ); 109 | 110 | const promise = new Promise((resolve, reject) => { 111 | this.pendingMessages.set(nonce, { resolve, reject }); 112 | }); 113 | 114 | return promise; 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /nested-messages/client/utils/initializeSdk.ts: -------------------------------------------------------------------------------- 1 | import { DiscordSDK } from '@discord/embedded-app-sdk'; 2 | 3 | /** 4 | * This function should be called from the top-level of your embedded app 5 | * It initializes the SDK, then authorizes, and authenticates the embedded app 6 | */ 7 | export async function initializeSdk(): Promise { 8 | if (typeof process.env.CLIENT_ID !== 'string') { 9 | throw new Error("Must specify 'CLIENT_ID"); 10 | } 11 | const discordSdk = new DiscordSDK(process.env.CLIENT_ID); 12 | await discordSdk.ready(); 13 | 14 | // Pop open the OAuth permission modal and request for access to scopes listed in scope array below 15 | const { code } = await discordSdk.commands.authorize({ 16 | client_id: process.env.CLIENT_ID, 17 | response_type: 'code', 18 | state: '', 19 | prompt: 'none', 20 | scope: [ 21 | 'identify', 22 | 'applications.commands', 23 | 'rpc.activities.write', 24 | 'rpc.voice.read', 25 | ], 26 | }); 27 | 28 | // Retrieve an access_token from your embedded app's server 29 | const response = await fetch('/api/token', { 30 | method: 'POST', 31 | headers: { 32 | 'Content-Type': 'application/json', 33 | }, 34 | body: JSON.stringify({ 35 | code, 36 | }), 37 | }); 38 | 39 | const { access_token } = await response.json(); 40 | 41 | // Authenticate with Discord client (using the access_token) 42 | await discordSdk.commands.authenticate({ 43 | access_token, 44 | }); 45 | 46 | return discordSdk; 47 | } 48 | -------------------------------------------------------------------------------- /nested-messages/client/utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface MessageData { 2 | nonce?: string; 3 | event?: string; 4 | command?: string; 5 | data?: Record; 6 | args?: Record; 7 | } 8 | -------------------------------------------------------------------------------- /nested-messages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nested-messages", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "build": "node ./scripts/build.js", 8 | "start": "node server/index.js", 9 | "dev": "nodemon --watch client -e ts,html --exec \"$npm_execpath run build && $npm_execpath run start\"" 10 | }, 11 | "author": "Discord", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@discord/embedded-app-sdk": "^2.0.0", 15 | "dotenv": "^16.0.3", 16 | "esbuild": "^0.25.0", 17 | "eventemitter3": "^5.0.0", 18 | "express": "^4.19.2", 19 | "uuid": "^11.0.0" 20 | }, 21 | "devDependencies": { 22 | "glob": "^11.0.0", 23 | "nodemon": "^3.1.0", 24 | "typescript": "~5.8.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /nested-messages/scripts/build.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { build } = require('esbuild'); 3 | const { glob } = require('glob'); 4 | const entryPoints = glob.globSync('./client/**/*.ts'); 5 | 6 | // Inject .env variables 7 | const define = {}; 8 | for (const k in process.env) { 9 | if (!['CLIENT_SECRET'].includes(k)) { 10 | define[`process.env.${k}`] = JSON.stringify(process.env[k]); 11 | } 12 | } 13 | 14 | build({ 15 | bundle: true, 16 | entryPoints, 17 | outbase: './client', 18 | outdir: './client', 19 | platform: 'browser', 20 | external: [], 21 | define, 22 | }); 23 | -------------------------------------------------------------------------------- /nested-messages/server/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const path = require('node:path'); 3 | const express = require('express'); 4 | 5 | const app = express(); 6 | 7 | app.use(express.static(path.join(__dirname, '../client'))); 8 | app.use(express.json()); 9 | 10 | // Fetch token from developer portal and return to the embedded app 11 | app.post('/api/token', async (req, res) => { 12 | const response = await fetch('https://discord.com/api/oauth2/token', { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/x-www-form-urlencoded', 16 | }, 17 | body: new URLSearchParams({ 18 | client_id: process.env.CLIENT_ID, 19 | client_secret: process.env.CLIENT_SECRET, 20 | grant_type: 'authorization_code', 21 | code: req.body.code, 22 | }), 23 | }); 24 | 25 | const { access_token } = await response.json(); 26 | 27 | res.send({ access_token }); 28 | return; 29 | }); 30 | 31 | app.listen(3000); 32 | -------------------------------------------------------------------------------- /nested-messages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true 4 | }, 5 | "include": ["."], 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /nested-messages/types.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | CLIENT_ID: string; 5 | } 6 | } 7 | } 8 | 9 | export type {}; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embedded-app-sdk-examples", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Examples for the Discord Embedded App SDK", 6 | "keywords": [ 7 | "Embedded App SDK", 8 | "Examples" 9 | ], 10 | "author": "Discord", 11 | "license": "MIT", 12 | "scripts": { 13 | "fix": "biome check --write .", 14 | "lint": "biome check ." 15 | }, 16 | "devDependencies": { 17 | "@biomejs/biome": "^1.8.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '**/*' 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard", 6 | ":preserveSemverRanges" 7 | ], 8 | "ignorePaths": [ 9 | "**/node_modules/**" 10 | ], 11 | "packageRules": [ 12 | { 13 | "matchPackagePatterns": ["colyseus"], 14 | "groupName": "colyseus" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /sdk-playground/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # env files 4 | .env* 5 | .dev.vars 6 | 7 | # dependencies 8 | /node_modules 9 | /.pnp 10 | .pnp.js 11 | 12 | # testing 13 | /coverage 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .idea -------------------------------------------------------------------------------- /sdk-playground/README.md: -------------------------------------------------------------------------------- 1 | # SDK Playground 2 | 3 | ## Tech Stack 4 | 5 | This repo is an example built on top of the following frameworks 6 | 7 | 1. [ReactJS](https://reactjs.org/) - A frontend javascript UI framework 8 | 2. [Cloudflare Workers](https://developers.cloudflare.com/workers/) - A serverless execution environment 9 | 10 | ## Client architecture 11 | 12 | The client (aka front-end) is using [ViteJS](https://vitejs.dev/)'s React Typescript starter project. Vite has great starter projects in [many common javascript frameworks](https://vitejs.dev/guide/#trying-vite-online). All of these projects use the same config setup, which means that if you prefer VanillaJS, Svelte, etc... you can swap frameworks and still get the following: 13 | 14 | - Fast typescript bundling with hot-module-reloading 15 | - Identical configuration API 16 | - Identical environment variable API 17 | 18 | ## Server architecture 19 | 20 | The server (aka back-end) is using Cloudflare workers with typescript. Any file in the server project can be imported by the client, in case you need to share business logic. 21 | 22 | ## Setting up your Discord Application 23 | 24 | Before we write any code, lets follow the instructions [here](https://discord.com/developers/docs/activities/building-an-activity#step-1-creating-a-new-app) to make sure your Discord application is set up correctly. 25 | 26 | ![oauth2-details](https://github.com/discord/embedded-app-sdk/blob/main/assets/oauth2-details.png) 27 | 28 | ## Running your app locally 29 | 30 | As described [here](https://discord.com/developers/docs/activities/building-an-activity#step-4-running-your-app-locally-in-discord), we encourage using a tunnel solution such as [cloudflared](https://github.com/cloudflare/cloudflared#installing-cloudflared) for local development. 31 | To run your app locally, run the following from this directory (/examples/sdk-playground) 32 | 33 | ``` 34 | pnpm install # only need to run this the first time 35 | pnpm dev 36 | pnpm tunnel # from another terminal 37 | ``` 38 | 39 | Be sure to complete all the steps listed [here](https://discord.com/developers/docs/activities/building-an-activity) to ensure your development setup is working as expected. 40 | 41 | ### Adding a new environment variable 42 | 43 | In order to add new environment variables, you will need to do the following: 44 | 45 | 1. Add the environment key and value to `.env` 46 | 2. Add the key to [/packages/client/src/vite-env.d.ts](/packages/client/src/vite-env.d.ts) 47 | 3. Add the key to [/packages/server/src/types.ts](/packages/server/src/types.ts) 48 | 49 | This will ensure that you have type safety in your client and server code when consuming environment variables 50 | 51 | Per the [ViteJS docs](https://vitejs.dev/guide/env-and-mode.html#env-files) 52 | 53 | > To prevent accidentally leaking env variables to the client, only variables prefixed with VITE\_ are exposed to your Vite-processed code. 54 | 55 | ```env 56 | # Example .env file 57 | VITE_CLIENT_ID=123456789012345678 58 | CLIENT_SECRET=abcdefghijklmnopqrstuvwxyzabcdef # This should be the application oauth2 token from the developer portal. 59 | BOT_TOKEN=bot_token 60 | ``` 61 | 62 | See instructions on getting the oauth2 token and the bot token [here](https://discord.com/developers/docs/activities/building-an-activity#find-your-oauth2-credentials). 63 | 64 | # Manual Deployment 65 | 66 | Steps to manually deploy the embedded app 0. Have access to the Discord Dev cloudflare account 67 | 68 | 1. Log into cloudflare with your credentials associated with Discord Dev 69 | 70 | ```sh 71 | wrangler login 72 | ``` 73 | 74 | 2. Create or verify .env.production file 75 | If you haven't made it yet, copy the example.env file, rename it to `.env.production`, and add the `VITE_CLIENT_ID` and `CLIENT_SECRET` variables 76 | 77 | 3. Build and deploy the client 78 | 79 | ``` 80 | cd packages/client 81 | npm run build 82 | CLOUDFLARE_ACCOUNT_ID=867c81bb01731ca0dfff534a58ce67d7 npx wrangler pages publish dist 83 | ``` 84 | 85 | 4. Build and deploy the server 86 | 87 | ``` 88 | cd packages/server 89 | npm run deploy 90 | ``` 91 | 92 | # Testing SDK changes locally 93 | 94 | In order to test changes to the [embedded-app-sdk](https://github.com/discord/embedded-app-sdk) locally, follow these steps: 95 | 96 | - In a separate directory, clone the `embedded-app-sdk` github repo 97 | - From that directory, run `npm run dev` to continuously rebuild the SDK when changes are made 98 | - From inside of the sdk-playground's client directory, link the sdk via pnpm 99 | 100 | ```sh 101 | cd embedded-app-sdk-examples/sdk-playground-packages/client 102 | pnpm link ~/path/to/embedded-app-sdk # this is an example path 103 | cd embedded-app-sdk-examples/sdk-playground 104 | pnpm dev 105 | ``` 106 | 107 | You should now be up and running with your own local build of the embedded-app-sdk. Be sure to not commit the linked pnpm-lock.yaml file. 108 | 109 | Note - You may need to close and relaunch the activity for changes to the sdk to take effect 110 | -------------------------------------------------------------------------------- /sdk-playground/example.env: -------------------------------------------------------------------------------- 1 | # fill in the credentials for the desired environment (dev, staging, prod) 2 | # copy this file to appropriate .env file, and then remove the changes 3 | # for example, for prod env 4 | # `cp example.env .env.prod && git checkout example.env` 5 | 6 | VITE_CLIENT_ID=123456789 7 | VITE_APPLICATION_ID=123456789 8 | VITE_DISCORD_API_BASE=https://discord.com/api 9 | 10 | CLIENT_SECRET=secret # This should be the oauth2 token for your application from the developer portal. 11 | BOT_TOKEN=bottoken # This should be the bot token for your application from the developer portal. 12 | WEBAPP_SERVE_PORT=3000 -------------------------------------------------------------------------------- /sdk-playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sdk-playground", 3 | "description": "A project for exploring and testing embedded app features", 4 | "private": true, 5 | "version": "0.1.0", 6 | "author": "Discord", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "pnpm run --filter \"./packages/**\" --parallel dev", 10 | "tunnel": "cloudflared tunnel --url http://localhost:3000" 11 | }, 12 | "engines": { 13 | "node": ">=16.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/assets/baby-brick.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/embedded-app-sdk-examples/225c4b6bd617ccf20e56a39629ac907577c5c610/sdk-playground/packages/client/assets/baby-brick.jpeg -------------------------------------------------------------------------------- /sdk-playground/packages/client/assets/brick-pug-life.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/embedded-app-sdk-examples/225c4b6bd617ccf20e56a39629ac907577c5c610/sdk-playground/packages/client/assets/brick-pug-life.gif -------------------------------------------------------------------------------- /sdk-playground/packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SDK Playground 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "test": "$npm_execpath run tsc", 8 | "dev": "vite --mode dev", 9 | "staging": "vite --mode staging", 10 | "build": "tsc && vite build", 11 | "build-staging": "tsc && vite build --mode staging", 12 | "preview": "vite preview", 13 | "tsc": "tsc" 14 | }, 15 | "dependencies": { 16 | "@discord/embedded-app-sdk": "^2.0.0", 17 | "@radix-ui/colors": "^3.0.0", 18 | "@radix-ui/react-scroll-area": "^1.0.1", 19 | "@stitches/react": "^1.2.1", 20 | "lodash": "^4.17.21", 21 | "react": "^18.2.0", 22 | "react-device-detect": "^2.2.2", 23 | "react-dom": "^18.2.0", 24 | "react-json-tree": "^0.19.0", 25 | "react-router": "^6.16.0", 26 | "react-router-dom": "^6.16.0", 27 | "react-use": "^17.2.4", 28 | "web-vitals": "^3.0.0", 29 | "zustand": "^4.1.4" 30 | }, 31 | "devDependencies": { 32 | "@cloudflare/workers-types": "^4.0.0", 33 | "@testing-library/jest-dom": "^6.4.2", 34 | "@testing-library/react": "^16.0.0", 35 | "@testing-library/user-event": "^14.0.0", 36 | "@types/jest": "^29.5.12", 37 | "@types/lodash": "^4.14.200", 38 | "@types/node": "^22.0.0", 39 | "@types/react": "^18.2.34", 40 | "@types/react-dom": "^18.2.7", 41 | "@vitejs/plugin-react": "^4.0.0", 42 | "typescript": "~5.8.0", 43 | "vite": "^5.2.9" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | body { 5 | font-family: Arial, Helvetica, sans-serif; 6 | background: white; 7 | } 8 | 9 | #chatlog { 10 | position: fixed; 11 | top: 0; 12 | bottom: 32px; 13 | left: 0; 14 | right: 200px; 15 | overflow-y: auto; 16 | padding: 8px; 17 | overflow-wrap: break-word; 18 | } 19 | #chatlog span.username { 20 | font-weight: bold; 21 | } 22 | #spacer { 23 | height: calc(100vh - 32px - 5em); 24 | } 25 | 26 | #roster { 27 | font-weight: bold; 28 | padding: 8px; 29 | } 30 | 31 | p { 32 | margin-top: 0; 33 | margin-bottom: 8px; 34 | } 35 | p:last-of-type { 36 | margin: 0; 37 | } 38 | 39 | #roster { 40 | position: fixed; 41 | left: 0; 42 | top: 0; 43 | bottom: 32px; 44 | width: 200px; 45 | border-left: none; 46 | } 47 | 48 | ::-webkit-scrollbar { 49 | display: none; 50 | } 51 | 52 | @media (max-width: 600px) { 53 | #roster { 54 | display: none; 55 | } 56 | #chatlog { 57 | right: 0; 58 | } 59 | } 60 | 61 | #chat-input { 62 | position: fixed; 63 | width: 100%; 64 | height: 32px; 65 | bottom: 0; 66 | left: 0; 67 | border: none; 68 | border-top: none; 69 | padding-left: 32px; 70 | outline: none; 71 | } 72 | #chatroom::before { 73 | z-index: 1; 74 | display: block; 75 | content: ">"; 76 | position: fixed; 77 | bottom: 0; 78 | left: 0; 79 | width: 32px; 80 | height: 32px; 81 | line-height: 32px; 82 | text-align: center; 83 | font-weight: bold; 84 | color: #888; 85 | -webkit-text-stroke-width: 2px; 86 | } 87 | 88 | #name-form { 89 | position: fixed; 90 | z-index: 3; 91 | top: 0; 92 | bottom: 0; 93 | left: 0; 94 | right: 0; 95 | background-color: white; 96 | } 97 | 98 | #name-input { 99 | position: fixed; 100 | font-size: 200%; 101 | top: calc(50% - 1em); 102 | left: calc(50% - 8em); 103 | width: 16em; 104 | height: 2em; 105 | margin: 0; 106 | text-align: center; 107 | border: 1px solid #bbb; 108 | } 109 | 110 | #name-form p { 111 | position: fixed; 112 | top: calc(50% + 3em); 113 | width: 100%; 114 | text-align: center; 115 | } 116 | 117 | #room-form { 118 | position: fixed; 119 | z-index: 2; 120 | top: 0; 121 | bottom: 0; 122 | left: 0; 123 | right: 0; 124 | background-color: white; 125 | font-size: 200%; 126 | margin-top: calc(50vh - 3em); 127 | text-align: center; 128 | } 129 | 130 | #room-name { 131 | font-size: inherit; 132 | border: 1px solid #bbb; 133 | height: 2em; 134 | width: 16em; 135 | padding-left: 1em; 136 | } 137 | 138 | #room-form button { 139 | font-size: inherit; 140 | border: 1px solid #bbb; 141 | background-color: #eee; 142 | height: 2em; 143 | } 144 | 145 | @media (max-width: 660px) { 146 | #name-input, 147 | #room-form { 148 | font-size: 150%; 149 | } 150 | #name-form p { 151 | font-size: 75%; 152 | } 153 | } 154 | @media (max-width: 500px) { 155 | #name-input, 156 | #room-form { 157 | font-size: 100%; 158 | } 159 | #name-form p { 160 | font-size: 50%; 161 | } 162 | } 163 | 164 | #go-public { 165 | width: 4em; 166 | } 167 | #go-private { 168 | width: 20em; 169 | } 170 | 171 | .green-ball { 172 | width: 50px; 173 | height: 50px; 174 | position: fixed; 175 | border-radius: 50%; 176 | background-color: red; 177 | transition: all 350ms; 178 | transition-timing-function: linear; 179 | } 180 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {BrowserRouter as Router, Routes, Route, Link, useLocation, useNavigate} from 'react-router-dom'; 3 | 4 | import * as Scrollable from './components/Scrollable'; 5 | import {AuthProvider} from './components/AuthProvider'; 6 | import DesignSystemProvider from './components/DesignSystemProvider'; 7 | 8 | import AvatarAndName from './pages/AvatarAndName'; 9 | import CurrentGuild from './pages/CurrentGuild'; 10 | import CurrentUser from './pages/CurrentUser'; 11 | import CurrentGuildMember from './pages/CurrentGuildMember'; 12 | import EncourageHardwareAcceleration from './pages/EncourageHardwareAcceleration'; 13 | import Guilds from './pages/Guilds'; 14 | import Home from './pages/Home'; 15 | import InAppPurchase from './pages/InAppPurchase'; 16 | import OpenExternalLink from './pages/OpenExternalLink'; 17 | import OpenInviteDialog from './pages/OpenInviteDialog'; 18 | import OpenShareMomentDialog from './pages/OpenShareMomentDialog'; 19 | import InitiateImageUpload from './pages/InitiateImageUpload'; 20 | import OrientationLockState from './pages/OrientationLockState'; 21 | import OrientationUpdates from './pages/OrientationUpdates'; 22 | import LayoutMode from './pages/LayoutMode'; 23 | import PlatformBehaviors from './pages/PlatformBehaviors'; 24 | import QuickLink from './pages/QuickLink'; 25 | import ShareLink from './pages/ShareLink'; 26 | import VoiceState from './pages/VoiceState'; 27 | import VisibilityListener from './pages/VisibilityListener'; 28 | import WindowSizeTracker from './pages/WindowSizeTracker'; 29 | import GamepadTester from './pages/GamepadTester'; 30 | 31 | import * as S from './AppStyles'; 32 | import SafeAreas from './pages/SafeAreas'; 33 | import ThermalStates from './pages/ThermalStates'; 34 | import ActivityChannel from './pages/ActivityChannel'; 35 | import ActivityParticipants from './pages/ActivityParticipants'; 36 | import GetChannel from './pages/GetChannel'; 37 | import GetChannelPermissions from './pages/GetChannelPermissions'; 38 | import GetEntitlements from './pages/GetEntitlements'; 39 | import GetInstanceConnectedParticipants from './pages/GetInstanceConnectedParticipants'; 40 | import GetPlatformBehaviors from './pages/GetPlatformBehaviors'; 41 | import GetSkus from './pages/GetSkus'; 42 | import SetActivity from './pages/SetActivity'; 43 | import UserSettingsGetLocale from './pages/UserSettingsGetLocale'; 44 | import Search from './components/Search'; 45 | import {useState} from 'react'; 46 | import GetActivityInstance from "./pages/GetActivityInstance"; 47 | import CloseActivity from "./pages/CloseActivity"; 48 | 49 | import discordSdk from './discordSdk'; 50 | 51 | // Add contexts here 52 | export default function App(): React.ReactElement { 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | interface AppRoute { 65 | path: string; 66 | name: string; 67 | component: () => JSX.Element; 68 | } 69 | 70 | const routes: Record = { 71 | home: { 72 | path: '/', 73 | name: 'Home', 74 | component: Home, 75 | }, 76 | encourageHardwareAcceleration: { 77 | path: '/encourage-hw-acc', 78 | name: 'Encourage Hardware Acceleration', 79 | component: EncourageHardwareAcceleration, 80 | }, 81 | closeActivity: { 82 | path: '/close-activity', 83 | name: 'Close Activity', 84 | component: CloseActivity, 85 | }, 86 | getChannel: { 87 | path: '/get-channel', 88 | name: 'Get Channel', 89 | component: GetChannel, 90 | }, 91 | getChannelPermissions: { 92 | path: '/get-channel-permissions', 93 | name: 'Get Channel Permissions', 94 | component: GetChannelPermissions, 95 | }, 96 | getEntitlements: { 97 | path: '/get-entitlements', 98 | name: 'Get Entitlements', 99 | component: GetEntitlements, 100 | }, 101 | getInstanceConnectedParticipants: { 102 | path: '/get-instance-connected-participants', 103 | name: 'Get Instance Connected Participants', 104 | component: GetInstanceConnectedParticipants, 105 | }, 106 | getActivityInstance: { 107 | path: '/get-activity-instance', 108 | name: 'Get Activity Instance', 109 | component: GetActivityInstance, 110 | }, 111 | getPlatformBehaviors: { 112 | path: '/get-platform-behaviors', 113 | name: 'Get Platform Behaviors', 114 | component: GetPlatformBehaviors, 115 | }, 116 | getSkus: { 117 | path: '/get-skus', 118 | name: 'Get Skus', 119 | component: GetSkus, 120 | }, 121 | initiateImageUplaod: { 122 | path: '/initiateImageUpload', 123 | name: 'Initiate Image Upload', 124 | component: InitiateImageUpload, 125 | }, 126 | openExternalLink: { 127 | path: '/open-external-link', 128 | name: 'Open External Link', 129 | component: OpenExternalLink, 130 | }, 131 | openInviteDialog: { 132 | path: '/open-invite-dialog', 133 | name: 'Open Invite Dialog', 134 | component: OpenInviteDialog, 135 | }, 136 | openShareMomentDialog: { 137 | path: '/open-share-moment-dialog', 138 | name: 'Open Share Moment Dialog', 139 | component: OpenShareMomentDialog, 140 | }, 141 | quickLink: { 142 | path: '/quick-link', 143 | name: 'Quick Link', 144 | component: QuickLink, 145 | }, 146 | shareLink: { 147 | path: '/share-link', 148 | name: 'Share Link', 149 | component: ShareLink, 150 | }, 151 | setActivity: { 152 | path: '/set-activity', 153 | name: 'Set Activity', 154 | component: SetActivity, 155 | }, 156 | setOrientationLockState: { 157 | path: '/set-orientation-lock-state', 158 | name: 'Set Orientation Lock State', 159 | component: OrientationLockState, 160 | }, 161 | userSettingsGetLocale: { 162 | path: '/user-settings-get-locale', 163 | name: 'User Settings Get Locale', 164 | component: UserSettingsGetLocale, 165 | }, 166 | avatarAndName: { 167 | path: '/avatar-and-name', 168 | name: 'Avatar and Name', 169 | component: AvatarAndName, 170 | }, 171 | guilds: { 172 | path: '/guilds', 173 | name: 'Guilds', 174 | component: Guilds, 175 | }, 176 | voiceState: { 177 | path: '/voice-state', 178 | name: 'Voice State', 179 | component: VoiceState, 180 | }, 181 | activityChannel: { 182 | path: '/activity-channel', 183 | name: 'Activity Channel', 184 | component: ActivityChannel, 185 | }, 186 | currentGuild: { 187 | path: '/current-guild', 188 | name: 'Current Guild', 189 | component: CurrentGuild, 190 | }, 191 | currentUser: { 192 | path: '/current-user', 193 | name: 'Current User', 194 | component: CurrentUser, 195 | }, 196 | currentGuildMember: { 197 | path: '/current-guild-member', 198 | name: 'Current Guild Member', 199 | component: CurrentGuildMember, 200 | }, 201 | pipMode: { 202 | path: '/layout-mode', 203 | name: 'Layout Mode', 204 | component: LayoutMode, 205 | }, 206 | platformBehaviors: { 207 | path: '/platform-behaviors', 208 | name: 'Platform Behaviors', 209 | component: PlatformBehaviors, 210 | }, 211 | orientationUpdates: { 212 | path: '/orientation-updates', 213 | name: 'Orientation Updates', 214 | component: OrientationUpdates, 215 | }, 216 | safeAreas: { 217 | path: '/safe-areas', 218 | name: 'Safe Areas', 219 | component: SafeAreas, 220 | }, 221 | inAppPurchase: { 222 | path: '/in-app-purchase', 223 | name: 'In App Purchase', 224 | component: InAppPurchase, 225 | }, 226 | thermalStates: { 227 | path: '/thermal-states', 228 | name: 'Thermal States', 229 | component: ThermalStates, 230 | }, 231 | visibilityListener: { 232 | path: '/visibility-listener', 233 | name: 'Visibility Listener', 234 | component: VisibilityListener, 235 | }, 236 | windowSizeTracker: { 237 | path: '/window-size-tracker', 238 | name: 'Window Size Tracker', 239 | component: WindowSizeTracker, 240 | }, 241 | activityParticipants: { 242 | path: '/activity-participants', 243 | name: 'Activity Participants', 244 | component: ActivityParticipants, 245 | }, 246 | gamepad: { 247 | path: '/gamepad-tester', 248 | name: "Gamepad Tester", 249 | component: GamepadTester 250 | } 251 | }; 252 | 253 | let navigateOnInit = true; 254 | 255 | function RootedApp(): React.ReactElement { 256 | const location = useLocation(); 257 | const [query, setQuery] = useState(''); 258 | const navigate = useNavigate(); 259 | 260 | React.useEffect(() => { 261 | if (navigateOnInit === false || typeof discordSdk.customId !== 'string') { 262 | return; 263 | } 264 | 265 | if (routes.hasOwnProperty(discordSdk.customId) && 'path' in routes[discordSdk.customId]) { 266 | navigate(routes[discordSdk.customId].path); 267 | navigateOnInit = false; 268 | } 269 | }, [navigate]); 270 | 271 | return ( 272 | 273 | 281 | 282 | 283 | 284 | {Object.values(routes) 285 | .filter((r) => r.name.toLowerCase().includes(query.toLowerCase())) 286 | .map((r) => ( 287 | 288 |

{r.name}

289 |
290 | ))} 291 |
292 |
293 | 294 | 295 | 296 |
297 | 298 | 299 | 300 | {Object.values(routes).map((r) => ( 301 | } /> 302 | ))} 303 | 304 | 305 | 306 | 307 | 308 | 309 |
310 | ); 311 | } 312 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/AppStyles.tsx: -------------------------------------------------------------------------------- 1 | import {styled} from './styled'; 2 | import {Link} from 'react-router-dom'; 3 | import * as ScrollArea from '@radix-ui/react-scroll-area'; 4 | 5 | export const Navigation = styled(ScrollArea.Root, { 6 | height: '100%', 7 | width: '300px', 8 | overflow: 'auto', 9 | border: '1px solid', 10 | borderColor: '$slate12', 11 | }); 12 | 13 | export const Ul = styled('ul', { 14 | display: 'flex', 15 | flexDirection: 'column', 16 | }); 17 | 18 | export const Li = styled(Link, { 19 | padding: '24px', 20 | textDecoration: 'none', 21 | color: '$slate12', 22 | '&:visited': { 23 | color: '$slate12', 24 | }, 25 | '&:hover': { 26 | backgroundColor: '$slate4', 27 | }, 28 | variants: { 29 | selected: { 30 | true: { 31 | backgroundColor: '$indigo6', 32 | color: '$indigo12', 33 | '&:visited': { 34 | color: '$indigo12', 35 | }, 36 | '&:hover': { 37 | backgroundColor: '$indigo7', 38 | }, 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | export const Input = styled('input', { 45 | padding: '8px', 46 | marginBottom: '8px', 47 | width: '100%', 48 | border: '1px solid', 49 | borderColor: '$slate12', 50 | borderRadius: '4px', 51 | }); 52 | 53 | export const SiteWrapper = styled('div', { 54 | display: 'flex', 55 | height: '100%', 56 | color: '$slate12', 57 | flexDirection: 'row', 58 | '@small': { 59 | flexDirection: 'column', 60 | }, 61 | '@xsmall': { 62 | flexDirection: 'column', 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/Constants.ts: -------------------------------------------------------------------------------- 1 | const discordApiBase = import.meta.env.VITE_DISCORD_API_BASE; 2 | 3 | export const Constants = { 4 | WS_PORT: 3001, 5 | urls: { 6 | discord: discordApiBase, 7 | youtube: '/youtube', 8 | }, 9 | } as const; 10 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/DiscordAPI.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from './Constants'; 2 | 3 | export enum RequestType { 4 | GET = 'GET', 5 | PATCH = 'PATCH', 6 | PUT = 'PUT', 7 | POST = 'POST', 8 | DELETE = 'DELETE', 9 | } 10 | 11 | export interface DiscordAPIRequest { 12 | endpoint: string; 13 | method: RequestType; 14 | // biome-ignore lint/suspicious/noExplicitAny: 15 | body?: any; 16 | headers?: Record; 17 | stringifyBody?: boolean; 18 | } 19 | 20 | function request( 21 | { 22 | method, 23 | endpoint, 24 | body, 25 | headers: baseHeaders, 26 | stringifyBody = false, 27 | }: DiscordAPIRequest, 28 | accessToken: string, 29 | ): Promise { 30 | const headers: HeadersInit = { 31 | Authorization: `Bearer ${accessToken}`, 32 | ...baseHeaders, 33 | }; 34 | return fetch(`${Constants.urls.discord}${endpoint}`, { 35 | method, 36 | headers, 37 | body: stringifyBody === true ? JSON.stringify(body) : body, 38 | }) 39 | .then((response) => { 40 | if (!response.ok || response.status >= 400) { 41 | console.error(`error: ${response.body}`); 42 | throw new Error(`error${response.status}`); 43 | } 44 | return response.json(); 45 | }) 46 | .catch((e) => { 47 | console.error(`error: ${JSON.stringify(e)}`); 48 | throw e; 49 | }); 50 | } 51 | 52 | export const DiscordAPI = { 53 | request, 54 | }; 55 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/actions/authActions.ts: -------------------------------------------------------------------------------- 1 | import type { Types } from '@discord/embedded-app-sdk'; 2 | import discordSdk from '../discordSdk'; 3 | import { authStore } from '../stores/authStore'; 4 | import type { IGuildsMembersRead } from '../types'; 5 | 6 | export const start = async () => { 7 | const { user } = authStore.getState(); 8 | 9 | if (user != null) { 10 | return; 11 | } 12 | 13 | await discordSdk.ready(); 14 | 15 | // Authorize with Discord Client 16 | const { code } = await discordSdk.commands.authorize({ 17 | client_id: import.meta.env.VITE_CLIENT_ID, 18 | response_type: 'code', 19 | state: '', 20 | prompt: 'none', 21 | // More info on scopes here: https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes 22 | scope: [ 23 | // Activities will launch through app commands and interactions of user-installable apps. 24 | // https://discord.com/developers/docs/tutorials/developing-a-user-installable-app#configuring-default-install-settings-adding-default-install-settings 25 | 'applications.commands', 26 | 27 | // "applications.builds.upload", 28 | // "applications.builds.read", 29 | // "applications.store.update", 30 | // "applications.entitlements", 31 | // "bot", 32 | 'identify', 33 | // "connections", 34 | // "email", 35 | // "gdm.join", 36 | 'guilds', 37 | // "guilds.join", 38 | 'guilds.members.read', 39 | // "messages.read", 40 | // "relationships.read", 41 | 'rpc.activities.write', 42 | // "rpc.notifications.read", 43 | 'rpc.voice.write', 44 | 'rpc.voice.read', 45 | // "webhook.incoming", 46 | // discordSdk.guildId == null ? 'dm_channels.read' : null, // This scope requires approval from Discord. 47 | ].filter((scope) => scope != null) as Types.OAuthScopes[], 48 | }); 49 | 50 | // Retrieve an access_token from your embedded app's server 51 | const response = await fetch('/api/token', { 52 | method: 'POST', 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | }, 56 | body: JSON.stringify({ 57 | code, 58 | }), 59 | }); 60 | 61 | const { access_token } = await response.json<{ access_token: string }>(); 62 | // Authenticate with Discord client (using the access_token) 63 | const authResponse = await discordSdk.commands.authenticate({ 64 | access_token, 65 | }); 66 | 67 | // Get guild specific nickname and avatar, and fallback to user name and avatar 68 | const guildMember = await fetch( 69 | `https://discord.com/api/users/@me/guilds/${discordSdk.guildId}/member`, 70 | { 71 | method: 'get', 72 | headers: { Authorization: `Bearer ${access_token}` }, 73 | }, 74 | ) 75 | .then((j) => j.json()) 76 | .catch(() => { 77 | return null; 78 | }); 79 | 80 | // Done with discord-specific setup 81 | 82 | const authState = { 83 | ...authResponse, 84 | user: { 85 | ...authResponse.user, 86 | id: 87 | new URLSearchParams(window.location.search).get('user_id') ?? 88 | authResponse.user.id, 89 | }, 90 | guildMember, 91 | }; 92 | 93 | authStore.setState({ 94 | ...authState, 95 | }); 96 | }; 97 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {LoadingScreen} from './LoadingScreen'; 3 | import {authStore} from '../stores/authStore'; 4 | import {start} from '../actions/authActions'; 5 | 6 | export function AuthProvider({children}: {children: React.ReactNode}) { 7 | const auth = authStore(); 8 | 9 | React.useEffect(() => { 10 | start().catch(e => { 11 | console.error('Error starting auth', e); 12 | }) 13 | }, []); 14 | 15 | if (auth.user == null) { 16 | return ; 17 | } 18 | 19 | return <>{children}; 20 | } 21 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/DesignSystemProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {styled, darkTheme, globalCss} from '../styled'; 3 | 4 | const colorSchemes = ['light', 'dark']; 5 | 6 | const global = globalCss({ 7 | '*': { 8 | margin: 0, 9 | padding: 0, 10 | boxSizing: 'border-box', 11 | }, 12 | ':root': { 13 | '--sait': 'var(--discord-safe-area-inset-top, env(safe-area-inset-top))', 14 | '--saib': 'var(--discord-safe-area-inset-bottom, env(safe-area-inset-bottom))', 15 | '--sail': 'var(--discord-safe-area-inset-left, env(safe-area-inset-left))', 16 | '--sair': 'var(--discord-safe-area-inset-right, env(safe-area-inset-right))', 17 | }, 18 | body: { 19 | paddingTop: 'var(--sait)', 20 | paddingBottom: 'var(--saib)', 21 | paddingLeft: 'var(--sail)', 22 | paddingRight: 'var(--sair)', 23 | }, 24 | 'html,body,#root': { 25 | width: '100%', 26 | height: '100%', 27 | backgroundColor: '$slate1', 28 | fontFamily: '"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif', 29 | }, 30 | }); 31 | 32 | const Container = styled('div', { 33 | width: '100%', 34 | height: '100%', 35 | backgroundColor: '$slate1', 36 | }); 37 | 38 | const MEDIA = '(prefers-color-scheme: dark)'; 39 | 40 | // Helpers 41 | const getTheme = (key: string, fallback?: string) => { 42 | if (typeof window === 'undefined') return undefined; 43 | let theme; 44 | try { 45 | theme = localStorage.getItem(key) || undefined; 46 | } catch (e) { 47 | // Unsupported 48 | } 49 | return theme || fallback; 50 | }; 51 | 52 | const getSystemTheme = (e?: MediaQueryList): SystemTheme => { 53 | if (!e) { 54 | e = window.matchMedia(MEDIA); 55 | } 56 | 57 | const isDark = e.matches; 58 | const systemTheme = isDark ? 'dark' : 'light'; 59 | return systemTheme; 60 | }; 61 | type SystemTheme = 'dark' | 'light'; 62 | 63 | interface IValueObject { 64 | [themeName: string]: string; 65 | } 66 | 67 | export interface IUseThemeProps { 68 | /** List of all available theme names */ 69 | themes: string[]; 70 | /** Update the theme */ 71 | setTheme: (theme: string) => void; 72 | /** Active theme name */ 73 | theme?: string; 74 | /** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */ 75 | resolvedTheme?: string; 76 | /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ 77 | systemTheme?: 'dark' | 'light'; 78 | } 79 | 80 | export interface IThemeProviderProps { 81 | /** List of all available theme names */ 82 | themes?: string[]; 83 | /** Whether to switch between dark and light themes based on prefers-color-scheme */ 84 | enableSystem?: boolean; 85 | /** Disable all CSS transitions when switching themes */ 86 | disableTransitionOnChange?: boolean; 87 | /** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */ 88 | enableColorScheme?: boolean; 89 | /** Key used to store theme setting in localStorage */ 90 | storageKey?: string; 91 | /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ 92 | defaultTheme?: string; 93 | /** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */ 94 | attribute?: string | 'class'; 95 | /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ 96 | value?: IValueObject; 97 | children: React.ReactNode; 98 | } 99 | 100 | export default function DesignSystemProvider({ 101 | enableSystem = true, 102 | enableColorScheme = true, 103 | storageKey = 'theme', 104 | defaultTheme = enableSystem ? 'system' : 'light', 105 | children, 106 | }: IThemeProviderProps) { 107 | const [theme, setThemeState] = React.useState(() => getTheme(storageKey, defaultTheme)); 108 | const [resolvedTheme, setResolvedTheme] = React.useState(() => getTheme(storageKey)); 109 | 110 | const changeTheme = React.useCallback( 111 | (theme: SystemTheme, updateStorage = true) => { 112 | if (updateStorage) { 113 | try { 114 | localStorage.setItem(storageKey, theme); 115 | } catch (e) { 116 | // Unsupported 117 | } 118 | } 119 | }, 120 | [storageKey], 121 | ); 122 | 123 | const handleMediaQuery = React.useCallback( 124 | (e?: MediaQueryList) => { 125 | const systemTheme = getSystemTheme(e); 126 | setResolvedTheme(systemTheme); 127 | if (theme === 'system') changeTheme(systemTheme, false); 128 | }, 129 | [changeTheme, theme], 130 | ); 131 | 132 | // Ref hack to avoid adding handleMediaQuery as a dep 133 | const mediaListener = React.useRef(handleMediaQuery); 134 | mediaListener.current = handleMediaQuery; 135 | 136 | React.useEffect(() => { 137 | const handler = (...args: any) => mediaListener.current(...args); 138 | 139 | // Always listen to System preference 140 | const media = window.matchMedia(MEDIA); 141 | 142 | // Intentionally use deprecated listener methods to support iOS & old browsers 143 | media.addListener(handler); 144 | handler(media); 145 | 146 | return () => media.removeListener(handler); 147 | }, []); 148 | 149 | const setTheme = React.useCallback( 150 | (newTheme: SystemTheme) => { 151 | changeTheme(newTheme); 152 | setThemeState(newTheme); 153 | }, 154 | [changeTheme], 155 | ); 156 | 157 | // localStorage event handling 158 | React.useEffect(() => { 159 | const handleStorage = (e: StorageEvent) => { 160 | if (e.key !== storageKey) { 161 | return; 162 | } 163 | // If default theme set, use it if localstorage === null (happens on local storage manual deletion) 164 | const theme = e.newValue || defaultTheme; 165 | setTheme(theme as SystemTheme); 166 | }; 167 | 168 | window.addEventListener('storage', handleStorage); 169 | return () => window.removeEventListener('storage', handleStorage); 170 | }, [defaultTheme, setTheme, storageKey]); 171 | 172 | // color-scheme handling 173 | React.useEffect(() => { 174 | if (!enableColorScheme) return; 175 | 176 | const colorScheme = 177 | // If regular theme is light or dark 178 | theme && colorSchemes.includes(theme) 179 | ? theme 180 | : // If theme is system, use the resolved version 181 | theme === 'system' 182 | ? resolvedTheme || null 183 | : null; 184 | 185 | // color-scheme tells browser how to render built-in elements like forms, scrollbars, etc. 186 | // if color-scheme is null, this will remove the property 187 | document.documentElement.style.setProperty('color-scheme', colorScheme); 188 | }, [enableColorScheme, theme, resolvedTheme]); 189 | 190 | global(); 191 | 192 | return {children}; 193 | } 194 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function LoadingScreen() { 4 | return
loading
; 5 | } 6 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/ReactJsonView.tsx: -------------------------------------------------------------------------------- 1 | import {JSONTree} from 'react-json-tree'; 2 | 3 | import {theme} from '../styled'; 4 | 5 | const jsonTheme = { 6 | base00: theme.colors.slate1.computedValue, // background 7 | base01: theme.colors.slate12.computedValue, // idk 8 | base02: theme.colors.slate6.computedValue, // lines 9 | base03: theme.colors.slate12.computedValue, // idk 10 | base04: theme.colors.slate10.computedValue, // object value count 11 | base05: theme.colors.slate12.computedValue, // idk 12 | base06: theme.colors.slate12.computedValue, // idk 13 | base07: theme.colors.slate12.computedValue, // object key 14 | base08: theme.colors.slate12.computedValue, // idk 15 | base09: theme.colors.slate12.computedValue, // string kv 16 | base0A: theme.colors.slate12.computedValue, // idk 17 | base0B: theme.colors.slate12.computedValue, // idk 18 | base0C: theme.colors.slate12.computedValue, // array index 19 | base0D: theme.colors.slate10.computedValue, // arrow 20 | base0E: theme.colors.slate12.computedValue, // idk 21 | base0F: theme.colors.slate12.computedValue, // int 22 | }; 23 | 24 | export default function ReactJsonView({src}: {src: unknown}) { 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/Scrollable.tsx: -------------------------------------------------------------------------------- 1 | import {styled} from '../styled'; 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 3 | 4 | const SCROLLBAR_SIZE = 10; 5 | 6 | export const Root = styled(ScrollAreaPrimitive.Root, { 7 | overflow: 'hidden', 8 | }); 9 | 10 | export const Viewport = styled(ScrollAreaPrimitive.Viewport, { 11 | width: '100%', 12 | height: '100%', 13 | borderRadius: 'inherit', 14 | }); 15 | 16 | export const Scrollbar = styled(ScrollAreaPrimitive.Scrollbar, { 17 | display: 'flex', 18 | // ensures no selection 19 | userSelect: 'none', 20 | // disable browser handling of all panning and zooming gestures on touch devices 21 | touchAction: 'none', 22 | padding: 2, 23 | background: '$slate6', 24 | transition: 'background 160ms ease-out', 25 | '&:hover': {background: '$slate8'}, 26 | '&[data-orientation="vertical"]': {width: SCROLLBAR_SIZE}, 27 | '&[data-orientation="horizontal"]': { 28 | flexDirection: 'column', 29 | height: SCROLLBAR_SIZE, 30 | }, 31 | }); 32 | 33 | export const Thumb = styled(ScrollAreaPrimitive.Thumb, { 34 | flex: 1, 35 | background: '$slate10', 36 | borderRadius: SCROLLBAR_SIZE, 37 | // increase target size for touch devices https://www.w3.org/WAI/WCAG21/Understanding/target-size.html 38 | position: 'relative', 39 | '&::before': { 40 | content: '""', 41 | position: 'absolute', 42 | top: '50%', 43 | left: '50%', 44 | transform: 'translate(-50%, -50%)', 45 | width: '100%', 46 | height: '100%', 47 | minWidth: 44, 48 | minHeight: 44, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as S from '../AppStyles'; 3 | 4 | const Search = ({onQueryChanged}: {onQueryChanged: (q: string) => void}) => { 5 | const [query, setQuery] = React.useState(() => { 6 | return localStorage.getItem('sdkPlaygroundSearchQuery') ?? ''; 7 | }); 8 | 9 | React.useEffect(() => { 10 | localStorage.setItem('sdkPlaygroundSearchQuery', query); 11 | onQueryChanged(query); 12 | }, [onQueryChanged, query]); 13 | 14 | return setQuery(e.target.value)} />; 15 | }; 16 | 17 | export default Search; -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/declare.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/embedded-app-sdk-examples/225c4b6bd617ccf20e56a39629ac907577c5c610/sdk-playground/packages/client/src/declare.d.ts -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/discordSdk.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DiscordSDK, 3 | DiscordSDKMock, 4 | IDiscordSDK, 5 | patchUrlMappings, 6 | } from "@discord/embedded-app-sdk"; 7 | 8 | const queryParams = new URLSearchParams(window.location.search); 9 | const isEmbedded = queryParams.get("frame_id") != null; 10 | 11 | let discordSdk: IDiscordSDK; 12 | 13 | if (isEmbedded) { 14 | discordSdk = new DiscordSDK(import.meta.env.VITE_CLIENT_ID, { 15 | disableConsoleLogOverride: true, 16 | }); 17 | patchUrlMappings([]); 18 | } else { 19 | discordSdk = new DiscordSDKMock( 20 | import.meta.env.VITE_CLIENT_ID, 21 | null, 22 | null, 23 | null 24 | ); 25 | // @ts-expect-error 26 | discordSdk.channelId = "test_channel_id"; 27 | let storedUserId = sessionStorage.getItem("user_id"); 28 | if (storedUserId == null) { 29 | // Set user_id to a random 8-character string, this gives us a consistent user id 30 | storedUserId = Math.random().toString(36).slice(2, 10); 31 | sessionStorage.setItem("user_id", storedUserId); 32 | } 33 | const queryParamsUserId = new URLSearchParams(window.location.search).get( 34 | "user_id" 35 | ); 36 | 37 | const userId = queryParamsUserId ?? storedUserId ?? ""; 38 | const discriminator = String(userId.charCodeAt(0) % 5); 39 | 40 | (discordSdk as DiscordSDKMock)._updateCommandMocks({ 41 | authenticate: () => 42 | Promise.resolve({ 43 | access_token: "mock_token", 44 | user: { 45 | username: userId, 46 | discriminator, 47 | id: userId, 48 | avatar: null, 49 | public_flags: 1, 50 | }, 51 | scopes: [], 52 | expires: new Date(2112, 1, 1).toString(), 53 | application: { 54 | description: "mock_app_description", 55 | icon: "mock_app_icon", 56 | id: "mock_app_id", 57 | name: "mock_app_name", 58 | }, 59 | }), 60 | }); 61 | } 62 | 63 | export default discordSdk; 64 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as serviceWorker from './serviceWorker'; 2 | import App from './App'; 3 | import React from 'react'; 4 | import {createRoot} from 'react-dom/client'; 5 | 6 | createRoot(document.getElementById('root')!).render(); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: http://bit.ly/CRA-PWA 11 | serviceWorker.unregister(); 12 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | const root = document.getElementById('root')!; 7 | render( 8 | 9 | 10 | , 11 | root, 12 | ); 13 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/ActivityChannel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {authStore} from '../stores/authStore'; 5 | import {ISDKError, type Types} from '@discord/embedded-app-sdk'; 6 | 7 | function instanceOfSdkError(object: any): object is ISDKError { 8 | return 'code' in object && 'message' in object; 9 | } 10 | 11 | export default function CurrentGuild() { 12 | const [channel, setChannel] = React.useState(null); 13 | const [error, setError] = React.useState(null); 14 | const auth = authStore(); 15 | 16 | React.useEffect(() => { 17 | async function update() { 18 | if (!auth) { 19 | return; 20 | } 21 | 22 | const channelId = discordSdk.channelId; 23 | if (channelId == null) { 24 | return; 25 | } 26 | 27 | try { 28 | const newChannel = await discordSdk.commands.getChannel({channel_id: channelId}); 29 | 30 | setChannel(newChannel); 31 | } catch (error: any) { 32 | if (instanceOfSdkError(error)) { 33 | setError(error); 34 | } 35 | } 36 | } 37 | update(); 38 | }, [auth]); 39 | 40 | return ( 41 |
42 |
43 |

Activity Channel

44 |
45 |
46 | {channel != null ? : null} 47 | {error != null ? ( 48 |
49 |

Error

50 | 51 |
52 | ) : null} 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/ActivityParticipants.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {EventPayloadData} from '@discord/embedded-app-sdk'; 4 | import {getUserDisplayName} from '../utils/getUserDisplayName'; 5 | 6 | export default function ActivityParticipants() { 7 | const [participants, setParticipants] = React.useState< 8 | EventPayloadData<'ACTIVITY_INSTANCE_PARTICIPANTS_UPDATE'>['participants'] 9 | >([]); 10 | const [speakingParticipants, setSpeakingParticipants] = React.useState([]); // Array of user ids who are currently speaking 11 | 12 | React.useEffect(() => { 13 | const updateParticipants = (res: EventPayloadData<'ACTIVITY_INSTANCE_PARTICIPANTS_UPDATE'>) => { 14 | setParticipants(res.participants); 15 | }; 16 | discordSdk.commands.getInstanceConnectedParticipants().then(updateParticipants); 17 | discordSdk.subscribe('ACTIVITY_INSTANCE_PARTICIPANTS_UPDATE', updateParticipants); 18 | 19 | return () => { 20 | discordSdk.unsubscribe('ACTIVITY_INSTANCE_PARTICIPANTS_UPDATE', updateParticipants); 21 | }; 22 | }, []); 23 | 24 | React.useEffect(() => { 25 | const addSpeakingParticipants = (res: EventPayloadData<'SPEAKING_START'>) => { 26 | setSpeakingParticipants((s) => [...s, res.user_id]); 27 | }; 28 | const removeSpeakingParticipants = (res: EventPayloadData<'SPEAKING_STOP'>) => { 29 | setSpeakingParticipants((speakingParticipants) => 30 | speakingParticipants.filter((speakingParticipant) => speakingParticipant !== res.user_id), 31 | ); 32 | }; 33 | discordSdk.subscribe('SPEAKING_START', addSpeakingParticipants, {channel_id: discordSdk.channelId}); 34 | discordSdk.subscribe('SPEAKING_STOP', removeSpeakingParticipants, {channel_id: discordSdk.channelId}); 35 | 36 | return () => { 37 | discordSdk.unsubscribe('SPEAKING_START', addSpeakingParticipants, {channel_id: discordSdk.channelId}); 38 | discordSdk.unsubscribe('SPEAKING_STOP', removeSpeakingParticipants, {channel_id: discordSdk.channelId}); 39 | }; 40 | }, []); 41 | return ( 42 |
43 |

Tracking instance participants and their speaking state

44 |
45 |

This example tracks who is participating in the activity and whether or not they are speaking.

46 |
47 |
48 |
49 | Username 50 |
51 |
52 | Speaking Status 53 |
54 | {participants.map((user) => { 55 | return ( 56 | 57 |
{getUserDisplayName({guildMember: null, user})}
58 |
{speakingParticipants.some((s) => s === user.id) ? 'Speaking' : 'Not Speaking'}
59 |
60 | ); 61 | })} 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/AvatarAndName.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {DiscordAPI, RequestType} from '../DiscordAPI'; 5 | import {authStore} from '../stores/authStore'; 6 | import {getUserAvatarUrl} from '../utils/getUserAvatarUrl'; 7 | import {getUserDisplayName} from '../utils/getUserDisplayName'; 8 | 9 | interface GuildsMembersRead { 10 | roles: string[]; 11 | nick: string | null; 12 | avatar: string | null; 13 | premium_since: string | null; 14 | joined_at: string; 15 | is_pending: boolean; 16 | pending: boolean; 17 | communication_disabled_until: string | null; 18 | user: { 19 | id: string; 20 | username: string; 21 | avatar: string | null; 22 | discriminator: string; 23 | public_flags: number; 24 | }; 25 | mute: boolean; 26 | deaf: boolean; 27 | } 28 | 29 | export default function AvatarAndName() { 30 | const auth = authStore.getState(); 31 | const [guildMember, setGuildMember] = React.useState(null); 32 | 33 | React.useEffect(() => { 34 | if (auth == null) { 35 | return; 36 | } 37 | // We store this in the auth object, but fetching it again to keep relevant patterns in one area 38 | DiscordAPI.request( 39 | {method: RequestType.GET, endpoint: `/users/@me/guilds/${discordSdk.guildId}/member`}, 40 | auth.access_token, 41 | ).then((reply) => { 42 | setGuildMember(reply); 43 | }); 44 | }, [auth]); 45 | 46 | if (!auth) { 47 | return <>; 48 | } 49 | 50 | // Note: instead of doing this here, your app's server could retrieve this 51 | // data by using the user's OAuth token 52 | 53 | const userAvatarUrl = getUserAvatarUrl({ 54 | guildMember: null, 55 | user: auth.user, 56 | }); 57 | 58 | // Get the user's guild-specific avatar url 59 | // If none, fall back to the user profile avatar 60 | // If no main avatar, use a default avatar 61 | const guildAvatarUrl = getUserAvatarUrl({ 62 | guildMember, 63 | user: auth.user, 64 | }); 65 | 66 | // Get the user's guild nickname. If none set, fall back to global_name, or username 67 | // Note - this name is note guaranteed to be unique 68 | const name = getUserDisplayName({guildMember, user: auth.user}); 69 | 70 | return ( 71 |
72 |
73 |

User Avatar and Name

74 | 88 |
89 |
90 |
91 |

User avatar, global name, and username

92 | avatar 93 |

User Avatar url: "{userAvatarUrl}"

94 |

Global Name: "{auth.user.global_name}"

95 |

Unique username: "{auth.user.username}"

96 |
97 |
98 |
99 |
100 |

Guild-specific user avatar and nickname

101 | {guildMember == null ? ( 102 |

...loading

103 | ) : ( 104 | <> 105 | avatar 106 |

Guild Member Avatar url: "{guildAvatarUrl}"

107 |

Guild nickname: "{name}"

108 | 109 | )} 110 |
111 |
112 | {guildMember == null ? null : ( 113 | <> 114 |
115 |
API response from {`/api/users/@me/guilds/${discordSdk.guildId}/member`}
116 | 117 | 118 | )} 119 |
120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/CloseActivity.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import { RPCCloseCodes } from '@discord/embedded-app-sdk'; 5 | 6 | export default function CloseActivity() { 7 | React.useEffect(() => { 8 | discordSdk.close(RPCCloseCodes.CLOSE_NORMAL, 'Activity closed'); 9 | }, []); 10 | 11 | return ( 12 |
13 |
14 |

Close the Activity

15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/CurrentGuild.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {DiscordAPI, RequestType} from '../DiscordAPI'; 5 | import {authStore} from '../stores/authStore'; 6 | 7 | /** Full guild "shape" here: https://discord.com/developers/docs/resources/guild#guild-object-guild-structure */ 8 | interface Guild { 9 | id: string; 10 | } 11 | 12 | export default function CurrentGuild() { 13 | const [guild, setGuild] = React.useState(null); 14 | const auth = authStore(); 15 | 16 | React.useEffect(() => { 17 | async function update() { 18 | if (!auth) { 19 | return; 20 | } 21 | const guildId = discordSdk.guildId; 22 | if (!guildId) { 23 | return; 24 | } 25 | const guilds = await DiscordAPI.request( 26 | {method: RequestType.GET, endpoint: `/users/@me/guilds`}, 27 | auth.access_token, 28 | ); 29 | const newGuild = guilds.find(({id}) => id === guildId) ?? null; 30 | setGuild(newGuild); 31 | } 32 | update(); 33 | }, [auth]); 34 | 35 | return ( 36 |
37 |
38 |

Current Guild

39 |
40 |
41 | {guild ? : null} 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/CurrentGuildMember.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {useLocation} from 'react-router-dom'; 5 | import {EventPayloadData} from '@discord/embedded-app-sdk'; 6 | 7 | export default function CurrentGuildMember() { 8 | const [currentGuildMember, setCurrentGuildMember] = 9 | React.useState | null>(null); 10 | const location = useLocation(); 11 | 12 | React.useEffect(() => { 13 | const {channelId} = discordSdk; 14 | if (!channelId) return; 15 | 16 | const handleCurrentGuildMemberUpdate = ( 17 | currentGuildMemberEvent: EventPayloadData<'CURRENT_GUILD_MEMBER_UPDATE'>, 18 | ) => { 19 | setCurrentGuildMember(currentGuildMemberEvent); 20 | }; 21 | 22 | const guildId = discordSdk.guildId; 23 | if (guildId) { 24 | discordSdk.subscribe('CURRENT_GUILD_MEMBER_UPDATE', handleCurrentGuildMemberUpdate, { 25 | guild_id: guildId, 26 | }); 27 | } 28 | 29 | return () => { 30 | if (guildId) { 31 | discordSdk.unsubscribe('CURRENT_GUILD_MEMBER_UPDATE', handleCurrentGuildMemberUpdate, { 32 | guild_id: guildId, 33 | }); 34 | } 35 | }; 36 | }, [location.search]); 37 | 38 | return ( 39 |
40 |
41 |

Event Subscription

42 |

CURRENT_GUILD_MEMBER_UPDATE

43 |
44 |
45 | {currentGuildMember ? : null} 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/CurrentUser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {useLocation} from 'react-router-dom'; 5 | import {EventPayloadData} from '@discord/embedded-app-sdk'; 6 | 7 | export default function CurrentUser() { 8 | const [currentUser, setCurrentUser] = React.useState | null>(null); 9 | const location = useLocation(); 10 | 11 | React.useEffect(() => { 12 | const {channelId} = discordSdk; 13 | if (!channelId) return; 14 | 15 | const handleCurrentUserUpdate = (currentUserEvent: EventPayloadData<'CURRENT_USER_UPDATE'>) => { 16 | setCurrentUser(currentUserEvent); 17 | }; 18 | discordSdk.subscribe('CURRENT_USER_UPDATE', handleCurrentUserUpdate); 19 | return () => { 20 | discordSdk.unsubscribe('CURRENT_USER_UPDATE', handleCurrentUserUpdate); 21 | }; 22 | }, [location.search]); 23 | 24 | return ( 25 |
26 |
27 |

Event Subscription

28 |

CURRENT_USER_UPDATE

29 |
30 |
31 | {currentUser ? : null} 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/EncourageHardwareAcceleration.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | 4 | export default function EncourageHardwareAcceleration() { 5 | const [hwAccEnabled, setHWAccEnabled] = React.useState(null); 6 | 7 | const doEncourageHardwareAcceleration = async () => { 8 | const {enabled} = await discordSdk.commands.encourageHardwareAcceleration(); 9 | setHWAccEnabled(enabled === true); 10 | }; 11 | const enabledString = hwAccEnabled === null ? 'unknown' : hwAccEnabled === true ? 'Yes' : 'No'; 12 | 13 | return ( 14 |
15 |

Is Hardware Acceleration Enabled?

16 |

{enabledString}

17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GamepadTester.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactJsonView from "../components/ReactJsonView"; 3 | import { useLocation } from "react-router-dom"; 4 | import { styled } from "../styled"; 5 | 6 | const GamepadCard = styled("div", { 7 | border: "1px solid $slate12", 8 | borderRadius: "4px", 9 | padding: "16px", 10 | marginBottom: "16px", 11 | maxWidth: "1200px", 12 | }); 13 | 14 | const GamepadGrid = styled("div", { 15 | display: "grid", 16 | gridTemplateColumns: "1fr 1fr", 17 | gap: "16px", 18 | marginTop: "12px", 19 | "@media (max-width: 600px)": { 20 | gridTemplateColumns: "1fr", 21 | }, 22 | }); 23 | 24 | const Section = styled("div", { 25 | border: "1px solid $slate6", 26 | padding: "12px 16px", 27 | borderRadius: "4px", 28 | background: "$slate2", 29 | minWidth: 0, // Prevent overflow in grid 30 | }); 31 | 32 | const InputRow = styled("div", { 33 | display: "flex", 34 | justifyContent: "space-between", 35 | alignItems: "center", 36 | padding: "4px 8px", 37 | marginBottom: "4px", 38 | border: "1px solid $slate6", 39 | borderRadius: "4px", 40 | transition: "all 0.2s ease", 41 | variants: { 42 | active: { 43 | true: { 44 | borderColor: "$indigo9", 45 | background: "$indigo3", 46 | }, 47 | }, 48 | }, 49 | }); 50 | 51 | const ValueBadge = styled("span", { 52 | padding: "2px 8px", 53 | borderRadius: "4px", 54 | fontSize: "0.9em", 55 | variants: { 56 | active: { 57 | true: { 58 | background: "$indigo9", 59 | color: "white", 60 | }, 61 | false: { 62 | background: "$slate4", 63 | color: "$slate11", 64 | }, 65 | }, 66 | }, 67 | }); 68 | 69 | const GamepadId = styled("div", { 70 | color: "$slate11", 71 | fontSize: "0.9em", 72 | }); 73 | 74 | const SectionTitle = styled("h3", { 75 | margin: "0 0 8px 0", 76 | color: "$slate12", 77 | fontWeight: "normal", 78 | fontSize: "0.9em", 79 | }); 80 | 81 | interface GamepadRendererProps { 82 | gamepad: Gamepad | null; 83 | index: number; 84 | } 85 | 86 | function GamepadRenderer({ gamepad, index }: GamepadRendererProps) { 87 | const mapping = React.useMemo(() => { 88 | if (!gamepad) return null; 89 | return gamepadMappings.find((m) => m.match(gamepad)); 90 | }, [gamepad]); 91 | 92 | if (gamepad === null) { 93 | return ( 94 | 95 |

Gamepad {index + 1}

96 |

Gamepad disconnected

97 |
98 | ); 99 | } 100 | 101 | return ( 102 | 103 |

Gamepad {index + 1}

104 | {gamepad.id} 105 | 106 | 107 |
108 | Axes 109 | {gamepad.axes.map((axis, idx) => ( 110 | 0.1}> 111 | 112 | {mapping?.map.axes[idx] ? mapping.map.axes[idx] : `Axis ${idx}`} 113 | 114 | 0.1}> 115 | {axis.toFixed(2)} 116 | 117 | 118 | ))} 119 |
120 | 121 |
122 | Buttons 123 | {gamepad.buttons.map((button, idx) => ( 124 | 0}> 125 | 126 | {mapping?.map.buttons[idx] ? mapping.map.buttons[idx] : `Button ${idx}`} 127 | 128 | 0}> 129 | {button.value.toFixed(2)} 130 | 131 | 132 | ))} 133 |
134 |
135 |
136 | ); 137 | } 138 | 139 | type GamepadMapping = { 140 | match: (gamepad: Gamepad) => boolean; 141 | map: { 142 | axes: { [key: number]: string }; 143 | buttons: { [key: number]: string }; 144 | }; 145 | }; 146 | 147 | const gamepadMappings: GamepadMapping[] = [ 148 | { 149 | match: (gamepad: Gamepad) => gamepad.id.toLowerCase().includes("xbox"), 150 | map: { 151 | axes: { 152 | 0: "left stick x", 153 | 1: "left stick y", 154 | 2: "right stick x", 155 | 3: "right stick y", 156 | }, 157 | buttons: { 158 | 0: "A", 159 | 1: "B", 160 | 2: "X", 161 | 3: "Y", 162 | 4: "LB", 163 | 5: "RB", 164 | 6: "LT", 165 | 7: "RT", 166 | 8: "view", 167 | 9: "menu", 168 | 10: "left stick", 169 | 11: "right stick", 170 | 12: "d-pad up", 171 | 13: "d-pad down", 172 | 14: "d-pad left", 173 | 15: "d-pad right", 174 | 16: "Xbox", 175 | 17: "Share" 176 | }, 177 | }, 178 | }, 179 | { 180 | match: (gamepad: Gamepad) => gamepad.id.includes("Backbone One"), 181 | map: { 182 | axes: { 183 | 0: "left stick x", 184 | 1: "left stick y", 185 | 2: "right stick x", 186 | 3: "right stick y", 187 | }, 188 | buttons: { 189 | 0: "A", 190 | 1: "B", 191 | 2: "X", 192 | 3: "Y", 193 | 4: "L1", 194 | 5: "R1", 195 | 6: "L2", 196 | 7: "R2", 197 | 8: "select", 198 | 9: "start", 199 | 10: "left stick", 200 | 11: "right stick", 201 | 12: "up", 202 | 13: "down", 203 | 14: "left", 204 | 15: "right", 205 | }, 206 | }, 207 | }, 208 | { 209 | match: (gamepad: Gamepad) => gamepad.id.includes("DualSense"), 210 | map: { 211 | axes: { 212 | 0: "left stick x", 213 | 1: "left stick y", 214 | 2: "right stick x", 215 | 3: "right stick y", 216 | }, 217 | buttons: { 218 | 0: "cross", 219 | 1: "circle", 220 | 2: "square", 221 | 3: "triangle", 222 | 4: "L1", 223 | 5: "R1", 224 | 6: "L2", 225 | 7: "R2", 226 | 8: "share", 227 | 9: "options", 228 | 10: "left stick", 229 | 11: "right stick", 230 | 16: "PS", 231 | 17: "touchpad", 232 | }, 233 | }, 234 | }, 235 | ]; 236 | 237 | export default function GamepadTester() { 238 | const [gamepads, setGamepads] = React.useState<(Gamepad | null)[]>([]); 239 | const animateRef = React.useRef(null); 240 | 241 | function animate() { 242 | setGamepads(navigator.getGamepads()); 243 | animateRef.current = requestAnimationFrame(animate); 244 | } 245 | 246 | React.useEffect(() => { 247 | function onGamepadConnected(event: GamepadEvent) { 248 | console.log("A gamepad connected:"); 249 | console.log(event.gamepad); 250 | setGamepads(navigator.getGamepads()); 251 | } 252 | 253 | function onGamepadDisconnected(event: GamepadEvent) { 254 | console.log("A gamepad disconnected:"); 255 | console.log(event.gamepad); 256 | setGamepads(navigator.getGamepads()); 257 | } 258 | 259 | window.addEventListener("gamepadconnected", onGamepadConnected); 260 | window.addEventListener("gamepaddisconnected", onGamepadDisconnected); 261 | 262 | animateRef.current = requestAnimationFrame(animate); 263 | 264 | return () => { 265 | window.removeEventListener("gamepadconnected", onGamepadConnected); 266 | window.removeEventListener("gamepaddisconnected", onGamepadDisconnected); 267 | cancelAnimationFrame(animateRef.current!); 268 | }; 269 | }, []); 270 | 271 | return ( 272 |
273 |

Gamepad tester

274 |

press any button to make your gamepad appear

275 | {gamepads.length === 0 &&

No gamepads connected

} 276 | 277 | {gamepads.map((gamepad, index) => ( 278 | 279 | ))} 280 |
281 | ); 282 | } 283 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetActivityInstance.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import ReactJsonView from '../components/ReactJsonView'; 4 | import {authStore} from '../stores/authStore'; 5 | 6 | export default function GetActivityInstance() { 7 | const [instance, setInstance] = React.useState(null); 8 | const auth = authStore(); 9 | 10 | React.useEffect(() => { 11 | async function update() { 12 | if (!auth) { 13 | return; 14 | } 15 | const instanceResponse = await fetch(`/api/activity-instance/${discordSdk.instanceId}`); 16 | setInstance(await instanceResponse.json()); 17 | } 18 | update(); 19 | }, [auth]); 20 | 21 | return ( 22 |
23 |
24 |

Current Validated Instance

25 |
26 |
27 | {instance ? : null} 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetChannel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetChannel() { 7 | const [channel, setChannel] = React.useState> | null>(null); 8 | 9 | React.useEffect(() => { 10 | async function getChannel() { 11 | if (discordSdk.channelId == null) return; 12 | const channel = await discordSdk.commands.getChannel({channel_id: discordSdk.channelId}); 13 | setChannel(channel); 14 | } 15 | getChannel(); 16 | }, []); 17 | 18 | return ( 19 |
20 |
21 |

Get Channel

22 |
23 |
24 | {channel == null ? null : } 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetChannelPermissions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetChannelPermissions() { 7 | const [channelPermissions, setChannelPermissions] = React.useState 9 | > | null>(null); 10 | 11 | React.useEffect(() => { 12 | async function getChannelPermissions() { 13 | const channelPermissions = await discordSdk.commands.getChannelPermissions(); 14 | setChannelPermissions(channelPermissions); 15 | } 16 | getChannelPermissions(); 17 | }, []); 18 | 19 | return ( 20 |
21 |
22 |

Get Channel Permisssions

23 |
24 |
25 | {channelPermissions == null ? null : } 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetEntitlements.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetEntitlements() { 7 | const [entitlements, setEntitlements] = React.useState 9 | > | null>(null); 10 | 11 | React.useEffect(() => { 12 | async function getEntitlements() { 13 | const entitlements = await discordSdk.commands.getEntitlements(); 14 | setEntitlements(entitlements); 15 | } 16 | getEntitlements(); 17 | }, []); 18 | 19 | return ( 20 |
21 |
22 |

Get Entitlements

23 |
24 |
25 | {entitlements == null ? null : } 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetInstanceConnectedParticipants.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetInstanceConnectedParticipants() { 7 | const [participants, setParticipants] = React.useState 9 | > | null>(null); 10 | 11 | React.useEffect(() => { 12 | async function getParticipants() { 13 | const participants = await discordSdk.commands.getInstanceConnectedParticipants(); 14 | setParticipants(participants); 15 | } 16 | getParticipants(); 17 | }, []); 18 | 19 | return ( 20 |
21 |
22 |

Get Instance Connected Participants

23 |
24 |
25 | {participants == null ? null : } 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetPlatformBehaviors.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetPlatformBehaviors() { 7 | const [platformBehaviors, setPlatformBehaviors] = React.useState 9 | > | null>(null); 10 | 11 | React.useEffect(() => { 12 | async function getPlatformBehaviors() { 13 | const platformBehaviors = await discordSdk.commands.getPlatformBehaviors(); 14 | setPlatformBehaviors(platformBehaviors); 15 | } 16 | getPlatformBehaviors(); 17 | }, []); 18 | 19 | return ( 20 |
21 |
22 |

Get Platform Behaviors

23 |
24 |
25 | {platformBehaviors == null ? null : } 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/GetSkus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | 6 | export default function GetSkus() { 7 | const [skus, setSkus] = React.useState> | null>(null); 8 | 9 | React.useEffect(() => { 10 | async function getSkus() { 11 | const skus = await discordSdk.commands.getSkus(); 12 | setSkus(skus); 13 | } 14 | getSkus(); 15 | }, []); 16 | 17 | return ( 18 |
19 |
20 |

Get Skus

21 |
22 |
23 | {skus == null ? null : } 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/Guilds.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useInterval} from 'react-use'; 3 | 4 | import {DiscordAPI, RequestType} from '../DiscordAPI'; 5 | import {authStore} from '../stores/authStore'; 6 | 7 | interface Guild { 8 | features: string[]; 9 | icon: string; 10 | id: string; 11 | name: string; 12 | owner: boolean; 13 | permissions: number; 14 | permissions_new: string; 15 | } 16 | 17 | export default function Guilds() { 18 | const auth = authStore(); 19 | const [guilds, setGuilds] = React.useState([]); 20 | const [loading, setLoading] = React.useState(true); 21 | 22 | const fetchGuilds = React.useCallback(() => { 23 | if (!auth) { 24 | return; 25 | } 26 | DiscordAPI.request( 27 | { 28 | method: RequestType.GET, 29 | endpoint: '/users/@me/guilds', 30 | }, 31 | auth.access_token, 32 | ) 33 | .then((val) => { 34 | setGuilds(val); 35 | if (loading) { 36 | setLoading(false); 37 | } 38 | }) 39 | .catch((_e) => { 40 | // console.log('request error', e); 41 | }); 42 | }, [auth, loading]); 43 | 44 | useInterval(fetchGuilds, 2000); 45 | 46 | return ( 47 |
48 |

Your Guilds

49 |
50 |
51 | {loading ? ( 52 |
Loading...
53 | ) : ( 54 |
60 | {} 61 | {guilds.map((g) => ( 62 |
63 | Server Icon 64 |
{g.name}
65 |
66 | ))} 67 |
68 | )} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import discordSdk from '../discordSdk'; 2 | 3 | export default function Home() { 4 | const instanceId = discordSdk.instanceId; 5 | const clientId = discordSdk.clientId; 6 | const channelId = discordSdk.channelId; 7 | const guildId = discordSdk.guildId; 8 | const platform = discordSdk.platform; 9 | const mobileAppVersion = discordSdk.mobileAppVersion; 10 | const sdkVersion = discordSdk.sdkVersion; 11 | return ( 12 |
13 |

Welcome to the SDK playground! 🎉

14 |

15 | This is a place for testing out all of the capabilities of the Embedded App SDK as well as validating 16 | platform-specific behavior related to Activities in Discord. 17 |

18 |

19 |

Basic Activity Info

20 |

Custom ID: {discordSdk.customId}

21 |

Instance ID: {instanceId}

22 |

Referrer ID: {discordSdk.referrerId}

23 |

Client ID: {clientId}

24 |

Channel ID: {channelId}

25 | { guildId != null ? (

Guild ID: {guildId}

) : null } 26 |

Platform: {platform}

27 | { mobileAppVersion != null ? (

Mobile App Version: {mobileAppVersion}

) : null } 28 |

Using SDK Version: {sdkVersion }

29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/InAppPurchase.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {authStore} from '../stores/authStore'; 3 | import discordSdk from '../discordSdk'; 4 | import ReactJsonView from '../components/ReactJsonView'; 5 | import {Sku, SkuType} from '../types'; 6 | import {PriceUtils} from '@discord/embedded-app-sdk'; 7 | 8 | interface Entitlement { 9 | sku_id: string; 10 | id: string; 11 | } 12 | 13 | export default function InAppPurchase() { 14 | const applicationId = import.meta.env.VITE_APPLICATION_ID; 15 | const {user} = authStore.getState(); 16 | 17 | const [showTestModeEntitlements, setShowTestModeEntitlements] = React.useState(false); 18 | 19 | const [inspectSkusRPC, setInspectSkusRPC] = React.useState(false); 20 | const [inspectSkusAPI, setInspectSkusAPI] = React.useState(false); 21 | const [inspectEntitlements, setInspectEntitlments] = React.useState(false); 22 | 23 | const [rpcSkus, setRpcSkus] = React.useState([]); 24 | const [apiSkus, setApiSkus] = React.useState([]); 25 | const [durableSkus, setDurableSkus] = React.useState([]); 26 | const [consumableSkus, setConsumableSkus] = React.useState([]); 27 | const [subscriptionSkus, setSubscriptionSkus] = React.useState([]); 28 | const [entitlements, setEntitlements] = React.useState([]); 29 | 30 | const skuIsAvailable: (sku: Sku) => boolean = (sku: Sku) => { 31 | const apiSku = apiSkus.find((apiSku) => apiSku.id == sku.id); 32 | // apiSkus are fetched from server and filtered for availability in iapHandler 33 | // (examples/sdk-playground/packages/server/src/handlers/iapHandler.ts) 34 | // as a result, we can use the presence of the sku from API as proxy for availability 35 | return apiSku != null; 36 | }; 37 | 38 | const entitlementForSku: (sku: Sku) => Entitlement | undefined = (sku: Sku) => { 39 | const entitlement = entitlements.find((ent) => ent.sku_id == sku.id); 40 | return entitlement; 41 | }; 42 | 43 | const getSkusRPC = async () => { 44 | setRpcSkus([]); 45 | try { 46 | const {skus} = await discordSdk.commands.getSkus(); 47 | setRpcSkus(skus); 48 | } catch (e: any) { 49 | console.warn(e); 50 | } 51 | }; 52 | 53 | const getSkusAPI = React.useCallback(async () => { 54 | setApiSkus([]); 55 | try { 56 | const skusResp = await fetch(`/api/iap/${applicationId}/skus`); 57 | const skusJSON = await skusResp.json<{skus: Sku[]}>(); 58 | setApiSkus(skusJSON.skus); 59 | } catch (e: any) { 60 | console.warn(e); 61 | } 62 | }, [applicationId]); 63 | 64 | const getEntitlements = React.useCallback(async () => { 65 | setEntitlements([]); 66 | const entitlementsResp = await fetch( 67 | `/api/iap/${applicationId}/entitlements/${user.id}?show_test=${showTestModeEntitlements ? 1 : 0}`, 68 | ); 69 | const entitlementsJSON = await entitlementsResp.json<{entitlements: Entitlement[]}>(); 70 | setEntitlements(entitlementsJSON.entitlements); 71 | }, [applicationId, showTestModeEntitlements, user.id]); 72 | 73 | const startPurchase = async (skuId: string) => { 74 | try { 75 | await discordSdk.commands.startPurchase({sku_id: skuId}); 76 | } catch (e: any) { 77 | console.warn(e); 78 | } 79 | }; 80 | 81 | const consume = async (entitlementId: string) => { 82 | try { 83 | await fetch(`/api/iap/${applicationId}/consume/${entitlementId}`); 84 | await getEntitlements(); 85 | } catch (e: any) { 86 | console.warn(e); 87 | } 88 | }; 89 | 90 | const refresh = React.useCallback(() => { 91 | getSkusRPC(); 92 | getSkusAPI(); 93 | getEntitlements(); 94 | }, [getEntitlements, getSkusAPI]); 95 | 96 | React.useEffect(() => { 97 | refresh(); 98 | }, [applicationId, user.id, showTestModeEntitlements, refresh]); 99 | 100 | // partition durable vs consumable SKUs 101 | React.useEffect(() => { 102 | const durables: Sku[] = []; 103 | const consumables: Sku[] = []; 104 | const subscriptions: Sku[] = []; 105 | rpcSkus.forEach((sku) => { 106 | switch (sku.type) { 107 | case SkuType.CONSUMABLE: 108 | consumables.push(sku); 109 | break; 110 | case SkuType.DURABLE: 111 | durables.push(sku); 112 | break; 113 | case SkuType.SUBSCRIPTION: 114 | subscriptions.push(sku); 115 | break; 116 | default: 117 | } 118 | }); 119 | setDurableSkus(durables); 120 | setConsumableSkus(consumables); 121 | setSubscriptionSkus(subscriptions); 122 | }, [rpcSkus]); 123 | 124 | React.useEffect(() => { 125 | // when an entitlement is created, refetch entitlements from server 126 | discordSdk.subscribe('ENTITLEMENT_CREATE', getEntitlements); 127 | return () => { 128 | discordSdk.unsubscribe('ENTITLEMENT_CREATE', getEntitlements); 129 | }; 130 | }); 131 | 132 | return ( 133 |
134 |

In App Purchases

135 |
136 |
137 |

Consumable SKUs

138 | {consumableSkus.map((sku) => { 139 | const entitlement = entitlementForSku(sku); 140 | const owned = entitlement != null; 141 | const price = PriceUtils.formatPrice(sku.price); 142 | return ( 143 |
144 | 147 | 150 |
151 |
152 | ); 153 | })} 154 |
155 |
156 |
157 |

Persistent SKUs

158 | {durableSkus.map((sku) => { 159 | const price = PriceUtils.formatPrice(sku.price); 160 | return ( 161 |
162 | 167 |
168 |
169 | ); 170 | })} 171 |
172 |
173 |
174 |

Subscription SKUs

175 | {subscriptionSkus.map((sku) => { 176 | const price = PriceUtils.formatPrice(sku.price); 177 | return ( 178 |
179 | 184 |
185 |
186 | ); 187 | })} 188 |
189 |
190 |

Debug

191 |
192 | 193 |
194 |
195 |
196 |
197 |
198 |

Test Mode Entitlements {showTestModeEntitlements ? 'are' : 'are not'} included in fetched entitlements

199 | 202 |
203 |
204 |
205 | {inspectSkusAPI ? ( 206 |
207 | 208 |

SKUs

209 | 210 |
211 | ) : ( 212 | 213 | )} 214 |
215 |
216 |
217 | {inspectSkusRPC ? ( 218 |
219 | 220 |

SKUs

221 | 222 |
223 | ) : ( 224 | 225 | )} 226 |
227 |
228 |
229 | {inspectEntitlements ? ( 230 |
231 | 232 |

Entitlements

233 | 234 |
235 | ) : ( 236 | 237 | )} 238 |
239 |
240 | ); 241 | } 242 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/InitiateImageUpload.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | 4 | export default function InitiateImageUpload() { 5 | const [imageUrl, setImageUrl] = React.useState(); 6 | const [awaitingInitiateImageUpload, setAwaitingInitiateImageUpload] = React.useState(false); 7 | 8 | const doOpenAttachmentUpload = async () => { 9 | try { 10 | setAwaitingInitiateImageUpload(true); 11 | const response = await discordSdk.commands.initiateImageUpload(); 12 | if (response) { 13 | setImageUrl(response.image_url); 14 | } 15 | } catch (err: any) { 16 | console.log(err); 17 | } finally { 18 | setAwaitingInitiateImageUpload(false); 19 | } 20 | }; 21 | 22 | return ( 23 |
24 |

Initiate image upload

25 |
Awaiting initiateImageUpload? "{JSON.stringify(awaitingInitiateImageUpload)}"
26 | 27 | {imageUrl ? : null} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/LayoutMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {Common, EventPayloadData} from '@discord/embedded-app-sdk'; 4 | 5 | export default function LayoutMode() { 6 | const [layoutModeString, setLayoutModeString] = React.useState(''); 7 | 8 | const handleLayoutModeUpdate = React.useCallback((update: EventPayloadData<'ACTIVITY_LAYOUT_MODE_UPDATE'>) => { 9 | const layoutMode = update.layout_mode; 10 | let layoutModeStr = ''; 11 | switch (layoutMode) { 12 | case Common.LayoutModeTypeObject.FOCUSED: { 13 | layoutModeStr = 'FOCUSED'; 14 | break; 15 | } 16 | case Common.LayoutModeTypeObject.PIP: { 17 | layoutModeStr = 'PIP'; 18 | break; 19 | } 20 | case Common.LayoutModeTypeObject.GRID: { 21 | layoutModeStr = 'GRID'; 22 | break; 23 | } 24 | case Common.LayoutModeTypeObject.UNHANDLED: { 25 | layoutModeStr = 'UNHANDLED'; 26 | break; 27 | } 28 | } 29 | 30 | setLayoutModeString(layoutModeStr); 31 | }, []); 32 | 33 | React.useEffect(() => { 34 | discordSdk.subscribe('ACTIVITY_LAYOUT_MODE_UPDATE', handleLayoutModeUpdate); 35 | return () => { 36 | discordSdk.unsubscribe('ACTIVITY_LAYOUT_MODE_UPDATE', handleLayoutModeUpdate); 37 | }; 38 | }, [handleLayoutModeUpdate]); 39 | 40 | return ( 41 |
42 |

Embedded App Layout Mode

43 |

{layoutModeString}

44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/OpenExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import discordSdk from "../discordSdk"; 3 | 4 | enum LinkStatus { 5 | NOT_CLICKED = "NOT_CLICKED", 6 | CLICKED = "CLICKED", 7 | NOT_OPENED = "NOT_OPENED", 8 | OPENED = "OPENED", 9 | } 10 | 11 | // Note: we're still using the anchor tag, to ensure standard accessibility UX 12 | export default function OpenExternalLink() { 13 | const [linkStatus, setLinkStatus] = React.useState(LinkStatus.NOT_CLICKED); 14 | 15 | async function handleLinkClicked( 16 | e: React.MouseEvent 17 | ) { 18 | e.preventDefault(); 19 | setLinkStatus(LinkStatus.CLICKED); 20 | const { opened } = await discordSdk.commands.openExternalLink({ 21 | url: "https://google.com", 22 | }); 23 | if (opened) { 24 | setLinkStatus(LinkStatus.OPENED); 25 | } else { 26 | setLinkStatus(LinkStatus.NOT_OPENED); 27 | } 28 | } 29 | 30 | return ( 31 |
39 |

Open external link

40 |

41 | Click here to go to google:{" "} 42 | 43 | Google! 44 | 45 |

46 |
Link Status: {linkStatus}
47 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/OpenInviteDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {RPCErrorCodes, Permissions, PermissionUtils} from '@discord/embedded-app-sdk'; 4 | 5 | function isContextlessInstance(instanceId: string): boolean { 6 | return instanceId.includes('-cl-'); 7 | } 8 | 9 | export default function OpenInviteDialog() { 10 | const [message, setMessage] = React.useState('Checking for permissions...'); 11 | 12 | const [hasPermissionToInvite, setHasPermissionToInvite] = React.useState(false); 13 | 14 | React.useEffect(() => { 15 | const calculatePermissions = async () => { 16 | let canInvite = false; 17 | if (isContextlessInstance(discordSdk.instanceId)) { 18 | canInvite = true; // contextless means no rules B^) 19 | } else { 20 | const {permissions} = await discordSdk.commands.getChannelPermissions(); 21 | canInvite = PermissionUtils.can(Permissions.CREATE_INSTANT_INVITE, permissions); 22 | } 23 | setHasPermissionToInvite(canInvite); 24 | 25 | if (canInvite) { 26 | setMessage("Invite Dialog hasn't been opened... yet!"); 27 | } else { 28 | setMessage('You do not have permission to create invites to this channel!'); 29 | } 30 | }; 31 | calculatePermissions(); 32 | }); 33 | 34 | const doOpenInviteDialog = async () => { 35 | try { 36 | await discordSdk.commands.openInviteDialog(); 37 | setMessage('Invite Dialog opened!'); 38 | } catch (err: any) { 39 | if (err.code === RPCErrorCodes.INVALID_PERMISSIONS) { 40 | setMessage("You don't have permission to create invite!"); 41 | } else { 42 | const errorMessage = err.message ?? 'Unknown'; 43 | setMessage(`Failed to open Invite Dialog. Reason: ${errorMessage}`); 44 | } 45 | } 46 | }; 47 | return ( 48 |
49 |

Open Invite Dialog

50 |

{message}

51 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/OpenShareMomentDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {DiscordAPI, RequestType} from '../DiscordAPI'; 4 | import {authStore} from '../stores/authStore'; 5 | 6 | import babyBrick from '../../assets/baby-brick.jpeg'; 7 | import brickPugLife from '../../assets/brick-pug-life.gif'; 8 | 9 | const NAME_TO_IMG: {[name: string]: {src: any; width: number; height: number}} = { 10 | 'baby-brick': { 11 | src: babyBrick, 12 | width: 240, 13 | height: 320, 14 | }, 15 | 'pug-life': { 16 | src: brickPugLife, 17 | width: 292, 18 | height: 320, 19 | }, 20 | }; 21 | 22 | async function imageURLToFile(url: string): Promise { 23 | const response = await fetch(url); 24 | const blob = await response.blob(); 25 | const mimeType = blob.type; 26 | const buf = await blob.arrayBuffer(); 27 | return new File([buf], url, {type: mimeType}); 28 | } 29 | 30 | async function uploadImageAttachment(imageURL: string): Promise { 31 | const applicationId = import.meta.env.VITE_APPLICATION_ID; 32 | const auth = authStore.getState(); 33 | const imageFile = await imageURLToFile(imageURL); 34 | const body = new FormData(); 35 | body.append('file', imageFile); 36 | const resp = await DiscordAPI.request<{attachment: {url: string}}>( 37 | { 38 | method: RequestType.POST, 39 | endpoint: `/applications/${applicationId}/attachment`, 40 | body, 41 | stringifyBody: false, 42 | }, 43 | auth.access_token, 44 | ); 45 | return resp.attachment.url; 46 | } 47 | 48 | export default function OpenShareMomentDialog() { 49 | const [selectedImage, setSelectedImage] = React.useState('baby-brick'); 50 | const [posting, setPosting] = React.useState(false); 51 | 52 | const doOpenShareMomentDialog = async () => { 53 | setPosting(true); 54 | const imageURL = NAME_TO_IMG[selectedImage]?.src; 55 | if (imageURL == null) return; 56 | const mediaUrl = await uploadImageAttachment(imageURL); 57 | try { 58 | await discordSdk.commands.openShareMomentDialog({ 59 | mediaUrl, 60 | }); 61 | } catch (err: any) { 62 | // TODO 63 | } 64 | setPosting(false); 65 | }; 66 | return ( 67 |
68 |

Open Share Moment Dialog

69 | {Object.entries(NAME_TO_IMG).map(([name, {src, width, height}]) => { 70 | return ( 71 | {name} setSelectedImage(name)} 81 | /> 82 | ); 83 | })} 84 |
85 | 86 | {posting ?

... sharing in progress ...

: null} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/OrientationLockState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {Common} from '@discord/embedded-app-sdk'; 4 | 5 | enum DropdownOptions { 6 | UNDEFINED = 'undefined', 7 | NULL = 'null', 8 | LANDSCAPE = 'landscape', 9 | PORTRAIT = 'portrait', 10 | UNLOCKED = 'unlocked', 11 | } 12 | 13 | const OPTIONS = Object.values(DropdownOptions); 14 | type OrientationLockStateType = 15 | | typeof Common.OrientationLockStateTypeObject.LANDSCAPE 16 | | typeof Common.OrientationLockStateTypeObject.PORTRAIT 17 | | typeof Common.OrientationLockStateTypeObject.UNLOCKED; 18 | 19 | type NonNullDropdownOptions = DropdownOptions.UNLOCKED | DropdownOptions.LANDSCAPE | DropdownOptions.PORTRAIT; 20 | 21 | function getNonNullLockStateFromDropdownOption(dropdownOption: NonNullDropdownOptions): OrientationLockStateType { 22 | switch (dropdownOption) { 23 | case DropdownOptions.LANDSCAPE: 24 | return Common.OrientationLockStateTypeObject.LANDSCAPE; 25 | case DropdownOptions.PORTRAIT: 26 | return Common.OrientationLockStateTypeObject.PORTRAIT; 27 | case DropdownOptions.UNLOCKED: 28 | return Common.OrientationLockStateTypeObject.UNLOCKED; 29 | } 30 | } 31 | 32 | function getLockStateFromDropdownOption(dropdownOption: DropdownOptions): OrientationLockStateType | null | undefined { 33 | switch (dropdownOption) { 34 | case DropdownOptions.UNDEFINED: 35 | return undefined; 36 | case DropdownOptions.NULL: 37 | return null; 38 | case DropdownOptions.LANDSCAPE: 39 | return Common.OrientationLockStateTypeObject.LANDSCAPE; 40 | case DropdownOptions.PORTRAIT: 41 | return Common.OrientationLockStateTypeObject.PORTRAIT; 42 | case DropdownOptions.UNLOCKED: 43 | return Common.OrientationLockStateTypeObject.UNLOCKED; 44 | } 45 | } 46 | 47 | export default function OrientationLockState() { 48 | const [defaultLockStateDropdownOption, setDefaultLockStateDropdownOption] = React.useState( 49 | DropdownOptions.UNLOCKED, 50 | ); 51 | const [pipLockStateDropdownOption, setPipLockStateDropdownOption] = React.useState( 52 | DropdownOptions.UNDEFINED, 53 | ); 54 | const [gridLockStateDropdownOption, setGridLockStateDropdownOption] = React.useState( 55 | DropdownOptions.UNDEFINED, 56 | ); 57 | 58 | const onDefaultLockStateOptionSelected = React.useCallback((event: any) => { 59 | setDefaultLockStateDropdownOption(event.target.value); 60 | }, []); 61 | 62 | const onPipLockStateOptionSelected = React.useCallback((event: any) => { 63 | setPipLockStateDropdownOption(event.target.value); 64 | }, []); 65 | const onGridLockStateOptionSelected = React.useCallback((event: any) => { 66 | setGridLockStateDropdownOption(event.target.value); 67 | }, []); 68 | 69 | const onButtonClick = React.useCallback(() => { 70 | discordSdk.commands.setOrientationLockState({ 71 | lock_state: getNonNullLockStateFromDropdownOption(defaultLockStateDropdownOption), 72 | picture_in_picture_lock_state: getLockStateFromDropdownOption(pipLockStateDropdownOption), 73 | grid_lock_state: getLockStateFromDropdownOption(gridLockStateDropdownOption), 74 | }); 75 | }, [defaultLockStateDropdownOption, pipLockStateDropdownOption, gridLockStateDropdownOption]); 76 | 77 | return ( 78 |
79 |

Set Orientation Lock State on Mobile

80 |

default

81 | 86 |

picture in picture

87 | 92 |

grid

93 | 98 | 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/OrientationUpdates.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {Common, Events, EventPayloadData} from '@discord/embedded-app-sdk'; 4 | 5 | export default function OrientationUpdates() { 6 | const [orientationString, setOrientationString] = React.useState(''); 7 | 8 | const updateOrientation = React.useCallback<(u: EventPayloadData<'ORIENTATION_UPDATE'>) => void>((update) => { 9 | const screenOrientation = update.screen_orientation; 10 | let orientationStr; 11 | switch (screenOrientation) { 12 | case Common.OrientationTypeObject.PORTRAIT: 13 | orientationStr = 'PORTRAIT'; 14 | break; 15 | case Common.OrientationTypeObject.LANDSCAPE: 16 | orientationStr = 'LANDSCAPE'; 17 | break; 18 | default: 19 | orientationStr = 'UNHANDLED'; 20 | break; 21 | } 22 | 23 | setOrientationString(orientationStr); 24 | }, []); 25 | 26 | React.useEffect(() => { 27 | discordSdk.subscribe(Events.ORIENTATION_UPDATE, updateOrientation); 28 | return () => { 29 | discordSdk.unsubscribe(Events.ORIENTATION_UPDATE, updateOrientation); 30 | }; 31 | }, [updateOrientation]); 32 | 33 | return ( 34 |
35 |

Orientation Updates

36 |

app orientation: {orientationString}

37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/PlatformBehaviors.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {range} from 'lodash'; 3 | 4 | import discordSdk from '../discordSdk'; 5 | import ReactJsonView from '../components/ReactJsonView'; 6 | 7 | const fillerArray = range(0, 40); 8 | 9 | export default function PlatformBehaviors() { 10 | const [platformBehaviors, setPlatformBehaviors] = React.useState(null); 11 | 12 | const getPlatformBehaviors = React.useCallback(async () => { 13 | const behaviors = await discordSdk.commands.getPlatformBehaviors(); 14 | setPlatformBehaviors(behaviors); 15 | }, []); 16 | 17 | React.useEffect(() => { 18 | getPlatformBehaviors(); 19 | }, [getPlatformBehaviors]); 20 | 21 | return ( 22 |
23 |
24 |

Platform Behaviors

25 |
26 |
27 | 28 |
29 |
30 | {platformBehaviors == null ? null : } 31 |
32 |
33 | {fillerArray.map((i) => ( 34 |
Space {i}
35 | ))} 36 | 37 | {fillerArray.map((i) => ( 38 |
Space {i}
39 | ))} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /sdk-playground/packages/client/src/pages/QuickLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import discordSdk from '../discordSdk'; 3 | import {DiscordAPI, RequestType} from '../DiscordAPI'; 4 | import {authStore} from '../stores/authStore'; 5 | 6 | function arrayBufferToString(maybeBuffer: ArrayBuffer | string): string { 7 | return typeof maybeBuffer === 'string' ? maybeBuffer : new TextDecoder().decode(maybeBuffer); 8 | } 9 | 10 | function fileToBase64(file: File) { 11 | return new Promise((resolve, reject) => { 12 | const reader = new FileReader(); 13 | reader.readAsDataURL(file); 14 | 15 | reader.onload = (ev) => { 16 | if (ev.target == null) { 17 | return; 18 | } 19 | const {result} = ev.target; 20 | if (result == null || result === '') { 21 | return; 22 | } 23 | const imageString = arrayBufferToString(result); 24 | resolve(imageString); 25 | }; 26 | reader.onerror = (error) => reject(error); 27 | }); 28 | } 29 | 30 | export default function QuickLink() { 31 | const applicationId = import.meta.env.VITE_APPLICATION_ID; 32 | const [title, setTitle] = React.useState('Baby Brick'); 33 | const [description, setDescription] = React.useState('I\'m small but mighty...just like a Brick!'); 34 | const [customId, setCustomId] = React.useState(undefined); 35 | const [image, setImage] = React.useState(); 36 | 37 | const [hasPressedSend, setHasPressedSend] = React.useState(false); 38 | const [didSend, setDidSend] = React.useState(false); 39 | 40 | const inputImageRef = React.useRef(null); 41 | 42 | const onImageChange = async (event: React.ChangeEvent) => { 43 | const file = event.target.files?.[0]; 44 | if (file == null) { 45 | return; 46 | } 47 | const image = await fileToBase64(file); 48 | setImage(image); 49 | }; 50 | 51 | const handleTitleChange= (event: React.ChangeEvent) => { 52 | setTitle(event.target.value); 53 | }; 54 | const handleDescriptionChange= (event: React.ChangeEvent) => { 55 | setDescription(event.target.value); 56 | }; 57 | const handleCustomIdChange = (event: React.ChangeEvent) => { 58 | setCustomId(event.target.value); 59 | }; 60 | 61 | const auth = authStore.getState(); 62 | 63 | 64 | const doShareLink = async () => { 65 | // Generate the quick activity link 66 | const {link_id} = await DiscordAPI.request<{link_id: string}>( 67 | { 68 | method: RequestType.POST, 69 | endpoint: `/applications/${applicationId}/quick-links/`, 70 | headers: { 71 | 'Content-Type': 'application/json', 72 | }, 73 | body: { 74 | custom_id: customId, 75 | description, 76 | image, 77 | title, 78 | }, 79 | stringifyBody: true, 80 | }, 81 | auth.access_token, 82 | ); 83 | 84 | // Open the Share modal with the generated link 85 | const {success} = await discordSdk.commands.shareLink({ 86 | message: 'Come try out new features!', 87 | link_id: link_id 88 | }); 89 | 90 | setHasPressedSend(true); 91 | setDidSend(success); 92 | }; 93 | 94 | return ( 95 |
96 |

Image:

97 | 104 |
105 | 106 |

Title:

107 | 113 |
114 | 115 |

Description:

116 |