├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── deploy.yml │ └── automerge-dependabot.yml ├── assets ├── README.md ├── social.dio └── social.svg ├── src ├── vite-env.d.ts ├── index.tsx ├── Components │ ├── types.tsx │ ├── utils.tsx │ ├── footer.tsx │ ├── left-emoji-list.tsx │ ├── right-emoji-list.tsx │ ├── mobile-emoji-list.tsx │ ├── search.tsx │ └── kitchen.tsx └── app.tsx ├── .vscode ├── extensions.json └── settings.json ├── public ├── social.png ├── favicon.ico ├── robots.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── tsconfig.node.json ├── .devcontainer └── devcontainer.json ├── vite.config.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── index.html /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @xsalazar -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | ![](./social.svg) 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: xsalazar 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { "recommendations": ["hediet.vscode-drawio"] } 2 | -------------------------------------------------------------------------------- /public/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/social.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xsalazar/emoji-kitchen/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[typescriptreact]": { 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "explicit" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "node:24", 3 | "customizations": { 4 | "vscode": { 5 | "extensions": [ 6 | "esbenp.prettier-vscode", 7 | "streetsidesoftware.code-spell-checker", 8 | "GitHub.vscode-github-actions" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./app"; 4 | 5 | const container = document.getElementById("root"); 6 | const root = createRoot(container!); 7 | 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import viteTsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig(() => { 6 | return { 7 | build: { 8 | outDir: "build", 9 | }, 10 | plugins: [react(), viteTsconfigPaths()], 11 | server: { 12 | host: "127.0.0.1", 13 | }, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Main application 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | labels: 7 | - "dependabot :robot:" 8 | schedule: 9 | interval: "daily" 10 | 11 | # GitHub Actions 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | labels: 15 | - "dependabot :robot:" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | metadata.json 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src/Components/types.tsx: -------------------------------------------------------------------------------- 1 | export interface EmojiMetadata { 2 | knownSupportedEmoji: Array; 3 | data: { 4 | [emojiCodepoint: string]: EmojiData; 5 | }; 6 | } 7 | 8 | export interface EmojiData { 9 | alt: string; 10 | keywords: Array; 11 | emojiCodepoint: string; 12 | gBoardOrder: number; 13 | combinations: { [otherEmojiCodepoint: string]: Array }; 14 | } 15 | 16 | export interface EmojiCombination { 17 | gStaticUrl: string; 18 | alt: string; 19 | leftEmoji: string; 20 | leftEmojiCodepoint: string; 21 | rightEmoji: string; 22 | rightEmojiCodepoint: string; 23 | date: string; 24 | isLatest: boolean; 25 | gBoardOrder: number; 26 | } 27 | 28 | export interface MouseCoordinates { 29 | mouseX: number; 30 | mouseY: number; 31 | } 32 | -------------------------------------------------------------------------------- /src/Components/utils.tsx: -------------------------------------------------------------------------------- 1 | import emojiMetadata from "./metadata.json"; 2 | import { EmojiData, EmojiMetadata } from "./types"; 3 | 4 | /** 5 | * Converts an emoji codepoint into a printable emoji used for log statements 6 | */ 7 | export function toPrintableEmoji(emojiCodepoint: string): string { 8 | return String.fromCodePoint( 9 | ...emojiCodepoint.split("-").map((p) => parseInt(`0x${p}`)) 10 | ); 11 | } 12 | 13 | /** 14 | * Converts an emoji codepoint into a static github reference image url 15 | */ 16 | export function getNotoEmojiUrl(emojiCodepoint: string): string { 17 | return `https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u${emojiCodepoint 18 | .split("-") 19 | .filter((x) => x !== "fe0f") 20 | .map((x) => x.padStart(4, "0")) // Handle ©️ and ®️ 21 | .join("_")}.svg`; 22 | } 23 | 24 | export function getEmojiData(emojiCodepoint: string): EmojiData { 25 | return (emojiMetadata as EmojiMetadata).data[emojiCodepoint]; 26 | } 27 | 28 | export function getSupportedEmoji(): Array { 29 | return (emojiMetadata as EmojiMetadata).knownSupportedEmoji; 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emoji-kitchen", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "vite build", 8 | "serve": "vite preview", 9 | "start": "vite" 10 | }, 11 | "browserslist": { 12 | "production": [ 13 | ">0.2%", 14 | "not dead", 15 | "not op_mini all" 16 | ], 17 | "development": [ 18 | "last 1 chrome version", 19 | "last 1 firefox version", 20 | "last 1 safari version" 21 | ] 22 | }, 23 | "devDependencies": { 24 | "@emotion/react": "^11.14.0", 25 | "@emotion/styled": "^11.14.1", 26 | "@mui/icons-material": "^7.3.6", 27 | "@mui/lab": "^7.0.0", 28 | "@mui/material": "^7.3.6", 29 | "@primer/octicons-react": "^19.21.1", 30 | "@types/file-saver": "^2.0.7", 31 | "@types/react": "^19.2.7", 32 | "@types/react-dom": "^19.2.3", 33 | "@uidotdev/usehooks": "^2.4.1", 34 | "@vitejs/plugin-react": "^5.1.2", 35 | "eslint-config-react-app": "^7.0.1", 36 | "file-saver": "^2.0.5", 37 | "jszip": "^3.10.1", 38 | "react": "^19.2.3", 39 | "react-dom": "^19.2.3", 40 | "typescript": "^5.9.3", 41 | "uuid": "^13.0.0", 42 | "vite": "^7.3.0", 43 | "vite-tsconfig-paths": "^6.0.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "src/**" 9 | - "public/**" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build-and-deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 🛎 17 | uses: actions/checkout@v6 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Download Emoji Kitchen Metadata 💾 22 | uses: xsalazar/actions/downloader@master 23 | with: 24 | url: "https://raw.githubusercontent.com/xsalazar/emoji-kitchen-backend/main/app/metadata.json" 25 | filename: "src/Components/metadata.json" 26 | 27 | - name: Setup Node 🏗 28 | uses: actions/setup-node@v6 29 | with: 30 | node-version: "24" 31 | 32 | - name: Install and Build 🔧 33 | run: | 34 | export NODE_OPTIONS="--max_old_space_size=8192" 35 | npm install 36 | npm run build 37 | 38 | - name: Deploy 🚀 39 | uses: JamesIves/github-pages-deploy-action@v4.7.6 40 | with: 41 | branch: gh-pages 42 | folder: build 43 | CLEAN: true # Automatically remove deleted files from the deploy branch 44 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | amber, 3 | blue, 4 | cyan, 5 | deepOrange, 6 | deepPurple, 7 | green, 8 | indigo, 9 | lightBlue, 10 | lightGreen, 11 | lime, 12 | orange, 13 | pink, 14 | purple, 15 | teal, 16 | } from "@mui/material/colors"; 17 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 18 | import Footer from "./Components/footer"; 19 | import Kitchen from "./Components/kitchen"; 20 | 21 | // 🌈 22 | const colors = [ 23 | amber, 24 | blue, 25 | cyan, 26 | deepOrange, 27 | deepPurple, 28 | green, 29 | indigo, 30 | lightBlue, 31 | lightGreen, 32 | lime, 33 | orange, 34 | pink, 35 | purple, 36 | teal, 37 | ]; 38 | 39 | const theme = createTheme({ 40 | palette: { 41 | primary: colors[Math.floor(Math.random() * colors.length)], 42 | }, 43 | }); 44 | 45 | export default function App() { 46 | if (window.self === window.top) { 47 | return ( 48 |
56 | 57 | 58 | 59 |
60 |
61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/automerge-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependabot PRs 2 | 3 | on: 4 | pull_request_target: 5 | workflow_dispatch: 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: write 10 | 11 | jobs: 12 | auto-merge: 13 | runs-on: ubuntu-latest 14 | 15 | # Checking the actor will prevent your Action run failing on non-Dependabot PRs 16 | if: ${{ github.actor == 'dependabot[bot]' }} 17 | 18 | steps: 19 | - name: Fetch Dependabot PR metadata 🎣 20 | id: dependabot-metadata 21 | uses: dependabot/fetch-metadata@v2.4.0 22 | with: 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | 25 | - name: Approve Dependabot PR ✅ 26 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor' || steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'}} 27 | run: gh pr review --approve "$PR_URL" 28 | env: 29 | PR_URL: ${{github.event.pull_request.html_url}} 30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 31 | 32 | - name: Auto-merge Dependabot PR 🪄 33 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor' || steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'}} 34 | run: gh pr merge --merge "$PR_URL" 35 | env: 36 | PR_URL: ${{github.event.pull_request.html_url}} 37 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧑‍🍳 Emoji Kitchen 2 | 3 | This repository contains the source code for the website [https://emojikitchen.dev](https://emojikitchen.dev). 4 | 5 | This website allows for quick and easy browsing of the comprehensive list of supported emoji mashups as part of Google's [Emoji Kitchen](https://emojipedia.org/emoji-kitchen/). 6 | 7 | There are currently over 100,000 possible valid combinations showcasing the unique illustrations and combined emoji! 8 | 9 | ## Getting Started 10 | 11 | This repository leverages [VSCode's devcontainer](https://code.visualstudio.com/docs/remote/containers) feature to ensure all necessary dependencies are available inside the container for development. 12 | 13 | ### Application 14 | 15 | To get started, you will need to first download the supporting metadata, then install and start the project normally: 16 | 17 | ```bash 18 | curl -L --compressed https://raw.githubusercontent.com/xsalazar/emoji-kitchen-backend/main/app/metadata.json -o src/Components/metadata.json 19 | npm install && npm start 20 | ``` 21 | 22 | This will start the application on your local machine, running on [http://localhost:5173/](http://localhost:5173). 23 | 24 | ### Deployments 25 | 26 | All application deployments are managed via GitHub Actions and the [`./.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) workflow. 27 | 28 | Additionally, application dependencies are automatically managed and updated via Dependabot and the [`./.github/workflows/automerge-dependabot.yml`](./.github/workflows/automerge-dependabot.yml) workflow. 29 | -------------------------------------------------------------------------------- /src/Components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box"; 2 | import Link from "@mui/material/Link"; 3 | import Stack from "@mui/material/Stack"; 4 | import Tooltip from "@mui/material/Tooltip"; 5 | import { 6 | FileCodeIcon, 7 | LinkExternalIcon, 8 | MentionIcon, 9 | } from "@primer/octicons-react"; 10 | 11 | export default function Footer() { 12 | return ( 13 |
14 | 15 | 16 | 17 | 24 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/Components/left-emoji-list.tsx: -------------------------------------------------------------------------------- 1 | import { ImageListItem } from "@mui/material"; 2 | import React, { Dispatch } from "react"; 3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils"; 4 | 5 | export default function LeftEmojiList({ 6 | handleLeftEmojiClicked, 7 | handleBulkImageDownloadMenuOpen, 8 | leftSearchResults, 9 | selectedLeftEmoji, 10 | }: { 11 | handleLeftEmojiClicked: Dispatch; 12 | handleBulkImageDownloadMenuOpen: Dispatch; 13 | leftSearchResults: Array; 14 | selectedLeftEmoji: string; 15 | }) { 16 | var knownSupportedEmoji = getSupportedEmoji(); 17 | 18 | // If we have search results, filter the top-level items down 19 | if (leftSearchResults.length > 0) { 20 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) => 21 | leftSearchResults.includes(emoji) 22 | ); 23 | } 24 | 25 | return knownSupportedEmoji.map((emojiCodepoint) => { 26 | const data = getEmojiData(emojiCodepoint); 27 | 28 | return ( 29 |
{} 35 | } 36 | > 37 | { 39 | handleLeftEmojiClicked(emojiCodepoint); 40 | }} 41 | sx={{ 42 | p: 0.5, 43 | borderRadius: 2, 44 | opacity: (_) => { 45 | return 1; 46 | }, 47 | backgroundColor: (theme) => 48 | selectedLeftEmoji === emojiCodepoint 49 | ? theme.palette.action.selected 50 | : theme.palette.background.default, 51 | "&:hover": { 52 | backgroundColor: (theme) => { 53 | return theme.palette.action.hover; 54 | }, 55 | }, 56 | }} 57 | > 58 | {data.alt} 65 | 66 |
67 | ); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 15 | 16 | 17 | 18 | Emoji Kitchen 19 | 20 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 46 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/Components/right-emoji-list.tsx: -------------------------------------------------------------------------------- 1 | import { ImageListItem } from "@mui/material"; 2 | import { Dispatch } from "react"; 3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils"; 4 | 5 | export default function RightEmojiList({ 6 | handleRightEmojiClicked, 7 | rightSearchResults, 8 | selectedLeftEmoji, 9 | selectedRightEmoji, 10 | }: { 11 | handleRightEmojiClicked: Dispatch; 12 | rightSearchResults: Array; 13 | selectedLeftEmoji: string; 14 | selectedRightEmoji: string; 15 | }) { 16 | var knownSupportedEmoji = getSupportedEmoji(); 17 | var hasSelectedLeftEmoji = selectedLeftEmoji !== ""; 18 | 19 | // If we have search results, filter the top-level items down 20 | if (rightSearchResults.length > 0) { 21 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) => 22 | rightSearchResults.includes(emoji) 23 | ); 24 | } 25 | 26 | // If we have a selectedLeftEmoji, save the valid combinations for that emoji 27 | var possibleEmoji: Array = []; 28 | if (hasSelectedLeftEmoji) { 29 | const data = getEmojiData(selectedLeftEmoji); 30 | possibleEmoji = Object.keys(data.combinations); 31 | } 32 | 33 | return knownSupportedEmoji.map((emojiCodepoint) => { 34 | const data = getEmojiData(emojiCodepoint); 35 | // Every right-hand emoji is valid unless we have a selected left-hand emoji 36 | // In which case, we need to explicitly check if it's a valid combination 37 | var isValidCombo = true; 38 | if (hasSelectedLeftEmoji) { 39 | isValidCombo = possibleEmoji.includes(emojiCodepoint); 40 | } 41 | 42 | return ( 43 |
44 | 46 | hasSelectedLeftEmoji && isValidCombo 47 | ? handleRightEmojiClicked(emojiCodepoint) 48 | : null 49 | } 50 | sx={{ 51 | p: 0.5, 52 | borderRadius: 2, 53 | opacity: (_) => { 54 | if (!hasSelectedLeftEmoji) { 55 | return 0.1; 56 | } 57 | 58 | return isValidCombo ? 1 : 0.1; 59 | }, 60 | backgroundColor: (theme) => 61 | selectedRightEmoji === emojiCodepoint 62 | ? theme.palette.action.selected 63 | : theme.palette.background.default, 64 | "&:hover": { 65 | backgroundColor: (theme) => { 66 | if (hasSelectedLeftEmoji) { 67 | return theme.palette.action.hover; 68 | } 69 | }, 70 | }, 71 | }} 72 | > 73 | {data.alt} 80 | 81 |
82 | ); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/Components/mobile-emoji-list.tsx: -------------------------------------------------------------------------------- 1 | import { ImageListItem } from "@mui/material"; 2 | import { Dispatch } from "react"; 3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils"; 4 | 5 | export default function MobileEmojiList({ 6 | handleEmojiClicked, 7 | searchResults, 8 | selectedEmoji, 9 | selectedOtherEmoji, 10 | selectedMode, 11 | }: { 12 | handleEmojiClicked: Dispatch; 13 | searchResults: Array; 14 | selectedEmoji: string; 15 | selectedOtherEmoji: string; 16 | selectedMode: string; 17 | }) { 18 | var knownSupportedEmoji = getSupportedEmoji(); 19 | 20 | if (selectedEmoji === "" || selectedOtherEmoji === "") { 21 | return; 22 | } 23 | 24 | // If we have search results, filter the top-level items down 25 | if (searchResults.length > 0) { 26 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) => 27 | searchResults.includes(emoji) 28 | ); 29 | } 30 | 31 | if (selectedMode === "combine") { 32 | const data = getEmojiData(selectedOtherEmoji); 33 | var possibleEmoji = Object.keys(data.combinations); 34 | 35 | return knownSupportedEmoji.map((emojiCodepoint) => { 36 | const data = getEmojiData(emojiCodepoint); 37 | 38 | const isValidCombo = possibleEmoji.includes(emojiCodepoint); 39 | 40 | return ( 41 |
42 | { 44 | // On mobile, only return this if it's a valid combination 45 | return isValidCombo ? handleEmojiClicked(emojiCodepoint) : null; 46 | }} 47 | sx={{ 48 | p: 0.5, 49 | borderRadius: 2, 50 | opacity: (_) => { 51 | return isValidCombo ? 1 : 0.1; 52 | }, 53 | backgroundColor: (theme) => 54 | selectedEmoji === emojiCodepoint 55 | ? theme.palette.action.selected 56 | : theme.palette.background.default, 57 | "&:hover": { 58 | backgroundColor: (theme) => { 59 | return theme.palette.action.hover; 60 | }, 61 | }, 62 | }} 63 | > 64 | {data.alt} 71 | 72 |
73 | ); 74 | }); 75 | } else { 76 | return knownSupportedEmoji.map((emojiCodepoint) => { 77 | const data = getEmojiData(emojiCodepoint); 78 | 79 | return ( 80 |
81 | handleEmojiClicked(emojiCodepoint)} 83 | sx={{ 84 | p: 0.5, 85 | borderRadius: 2, 86 | backgroundColor: (theme) => 87 | selectedEmoji === emojiCodepoint 88 | ? theme.palette.action.selected 89 | : theme.palette.background.default, 90 | "&:hover": { 91 | backgroundColor: (theme) => { 92 | return theme.palette.action.hover; 93 | }, 94 | }, 95 | }} 96 | > 97 | {data.alt} 104 | 105 |
106 | ); 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Components/search.tsx: -------------------------------------------------------------------------------- 1 | import { Close, Search as SearchIcon } from "@mui/icons-material"; 2 | import CircularProgress from "@mui/material/CircularProgress"; 3 | import Divider from "@mui/material/Divider"; 4 | import IconButton from "@mui/material/IconButton"; 5 | import InputBase from "@mui/material/InputBase"; 6 | import Paper from "@mui/material/Paper"; 7 | import Typography from "@mui/material/Typography"; 8 | import { useDebounce } from "@uidotdev/usehooks"; 9 | import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; 10 | import { getEmojiData, getNotoEmojiUrl } from "./utils"; 11 | 12 | export default function Search({ 13 | disabled, 14 | handleRandomize, 15 | isMobile, 16 | selectedEmoji, 17 | setSearchResults, 18 | uuid, 19 | }: { 20 | disabled?: boolean; 21 | handleRandomize?: () => void; 22 | isMobile: boolean; 23 | selectedEmoji?: string; 24 | setSearchResults: Dispatch>>; 25 | uuid: string; 26 | }) { 27 | const [value, setValue] = useState(""); 28 | const [isSearching, setIsSearching] = useState(false); 29 | const debouncedSearchTerm = useDebounce(value, 300); 30 | 31 | const hasSearchValue = value !== ""; 32 | 33 | /** 34 | * Hacky input to clear text box when full randomizing from parent element while search results are shown 35 | */ 36 | useEffect(() => { 37 | setValue(""); 38 | }, [uuid]); 39 | 40 | /** 41 | * Debounce and sanitize search queries 42 | */ 43 | useEffect(() => { 44 | async function search() { 45 | let results = []; 46 | setIsSearching(true); 47 | if (debouncedSearchTerm) { 48 | var requestQuery = debouncedSearchTerm.trim().toLowerCase(); 49 | requestQuery = 50 | requestQuery.length > 128 51 | ? requestQuery.substring(0, 127) 52 | : requestQuery; 53 | const data = await fetch( 54 | `https://backend.emojikitchen.dev/?q=${requestQuery}` 55 | ); 56 | results = await data.json(); 57 | } 58 | 59 | setIsSearching(false); 60 | setSearchResults(results); 61 | } 62 | 63 | if (debouncedSearchTerm.trim().length >= 3) { 64 | search(); 65 | } else { 66 | setSearchResults([]); 67 | } 68 | }, [debouncedSearchTerm]); 69 | 70 | if (isMobile) { 71 | return ( 72 | 84 | 85 | {isSearching ? ( 86 | 87 | ) : ( 88 | 89 | )} 90 | 91 | ) => { 96 | setValue(event.target.value); 97 | }} 98 | /> 99 | {hasSearchValue ? ( 100 | setValue("")} 104 | > 105 | 106 | 107 | ) : null} 108 | 109 | ); 110 | } 111 | 112 | return ( 113 | 125 | 126 | {isSearching ? ( 127 | 128 | ) : ( 129 | 130 | )} 131 | 132 | ) => { 137 | setValue(event.target.value); 138 | }} 139 | /> 140 | {hasSearchValue ? ( 141 | setValue("")} 145 | > 146 | 147 | 148 | ) : null} 149 | 150 | null : handleRandomize} 155 | > 156 | {selectedEmoji && selectedEmoji !== "" ? ( 157 | {getEmojiData(selectedEmoji).alt} 164 | ) : ( 165 | 173 | 🎲 174 | 175 | )} 176 | 177 | 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /assets/social.dio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Components/kitchen.tsx: -------------------------------------------------------------------------------- 1 | import ContentCopy from "@mui/icons-material/ContentCopy"; 2 | import Download from "@mui/icons-material/Download"; 3 | import LoadingButton from "@mui/lab/LoadingButton"; 4 | import Box from "@mui/material/Box"; 5 | import ButtonBase from "@mui/material/ButtonBase"; 6 | import Chip from "@mui/material/Chip"; 7 | import Container from "@mui/material/Container"; 8 | import Fab from "@mui/material/Fab"; 9 | import Fade from "@mui/material/Fade"; 10 | import Grid from "@mui/material/Grid"; 11 | import IconButton from "@mui/material/IconButton"; 12 | import ImageList from "@mui/material/ImageList"; 13 | import ImageListItem, { 14 | imageListItemClasses, 15 | } from "@mui/material/ImageListItem"; 16 | import Menu from "@mui/material/Menu"; 17 | import Paper from "@mui/material/Paper"; 18 | import Stack from "@mui/material/Stack"; 19 | import ToggleButton from "@mui/material/ToggleButton"; 20 | import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; 21 | import Typography from "@mui/material/Typography"; 22 | import saveAs from "file-saver"; 23 | import JSZip from "jszip"; 24 | import React, { useEffect, useState } from "react"; 25 | import { v4 as uuidv4 } from "uuid"; 26 | import LeftEmojiList from "./left-emoji-list"; 27 | import MobileEmojiList from "./mobile-emoji-list"; 28 | import RightEmojiList from "./right-emoji-list"; 29 | import Search from "./search"; 30 | import { MouseCoordinates } from "./types"; 31 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils"; 32 | 33 | export default function Kitchen() { 34 | // Selection helpers 35 | var [selectedLeftEmoji, setSelectedLeftEmoji] = useState(""); 36 | var [selectedRightEmoji, setSelectedRightEmoji] = useState(""); 37 | 38 | // Mobile helpers 39 | const [leftEmojiSelected, setLeftEmojiSelected] = useState(true); 40 | const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); 41 | const [isKeyboardOpen, setIsKeyboardOpen] = useState( 42 | window.innerHeight <= 512 43 | ); 44 | const [selectedMode, setSelectedMode] = useState("combine"); 45 | const [combinationCopied, setCombinationCopied] = useState(false); 46 | 47 | // Downloading helpers 48 | const [bulkDownloadMenu, setBulkDownloadMenu] = useState< 49 | MouseCoordinates | undefined 50 | >(); 51 | const [isBulkDownloading, setIsBulkDownloading] = useState(false); 52 | 53 | // Search results helpers 54 | const [leftSearchResults, setLeftSearchResults] = useState>([]); 55 | const [rightSearchResults, setRightSearchResults] = useState>( 56 | [] 57 | ); 58 | const [mobileSearchResults, setMobileSearchResults] = useState>( 59 | [] 60 | ); 61 | 62 | // Search terms helpers 63 | const [leftUuid, setLeftUuid] = useState(uuidv4()); 64 | const [rightUuid, setRightUuid] = useState(uuidv4()); 65 | const [mobileUuid, setMobileUuid] = useState(uuidv4()); 66 | 67 | /** 68 | * 📱 Mobile handler to naively detect if we're on a phone or not 69 | */ 70 | function handleWindowWidthChange() { 71 | window.innerWidth <= 768 ? setIsMobile(true) : setIsMobile(false); 72 | } 73 | useEffect(() => { 74 | window.addEventListener("resize", handleWindowWidthChange); 75 | return () => { 76 | window.removeEventListener("resize", handleWindowWidthChange); 77 | }; 78 | }, []); 79 | 80 | /** 81 | * 📱 Mobile handler to naively detect if we're on a phone or not 82 | */ 83 | function handleWindowHeightChange() { 84 | window.innerHeight <= 512 85 | ? setIsKeyboardOpen(true) 86 | : setIsKeyboardOpen(false); 87 | } 88 | useEffect(() => { 89 | window.addEventListener("resize", handleWindowHeightChange); 90 | return () => { 91 | window.removeEventListener("resize", handleWindowHeightChange); 92 | }; 93 | }, []); 94 | 95 | /** 96 | * 📱 Mobile handler to set a random combination on load 97 | */ 98 | useEffect(() => { 99 | if (isMobile) { 100 | handleFullEmojiRandomize(); 101 | } 102 | }, []); 103 | 104 | /** 105 | * 📱 Mobile handler to reset state when resizing window smaller to trigger mobile view 106 | */ 107 | useEffect(() => { 108 | if (!isMobile) { 109 | // Leaving mobile view should always be a subset of desktop functionality 110 | return; 111 | } 112 | 113 | if (selectedLeftEmoji === "" && selectedRightEmoji !== "") { 114 | handleLeftEmojiRandomize(); 115 | } else if (selectedLeftEmoji !== "" && selectedRightEmoji === "") { 116 | handleRightEmojiRandomize(); 117 | } else if (selectedLeftEmoji === "" && selectedRightEmoji === "") { 118 | handleFullEmojiRandomize(); 119 | } 120 | }, [isMobile]); 121 | 122 | /** 123 | * 🖨️ Handler to show the little chip when copying a combination on mobile from the browse tab 124 | */ 125 | useEffect(() => { 126 | if (combinationCopied) { 127 | setTimeout(() => { 128 | setCombinationCopied(false); 129 | }, 1000); 130 | } 131 | }, [combinationCopied]); 132 | 133 | /** 134 | * 👈 Handler when an emoji is selected from the left-hand list 135 | */ 136 | const handleLeftEmojiClicked = (clickedEmoji: string) => { 137 | if (isMobile) { 138 | // Don't allow columns unselect on mobile 139 | if (selectedLeftEmoji !== clickedEmoji) { 140 | setSelectedLeftEmoji(clickedEmoji); 141 | } 142 | } else { 143 | // If we're unsetting the left column, clear the right column too 144 | if (selectedLeftEmoji === clickedEmoji) { 145 | setSelectedLeftEmoji(""); 146 | setSelectedRightEmoji(""); 147 | } 148 | // Else we clicked another left emoji while both are selected, set the left column as selected and clear right column 149 | else if (selectedRightEmoji !== "") { 150 | setSelectedLeftEmoji(clickedEmoji); 151 | setSelectedRightEmoji(""); 152 | } else { 153 | setSelectedLeftEmoji(clickedEmoji); 154 | } 155 | } 156 | }; 157 | 158 | /** 159 | * 🎲 Handler when left-hand randomize button clicked 160 | */ 161 | const handleLeftEmojiRandomize = () => { 162 | if (isMobile) { 163 | // On mobile, use the right emoji as a base and select a random left emoji from the supported list 164 | const data = getEmojiData(selectedRightEmoji); 165 | const possibleLeftEmoji = Object.keys(data.combinations).filter( 166 | (codepoint) => codepoint !== selectedLeftEmoji // Don't randomly choose the same left emoji 167 | ); 168 | 169 | const randomLeftEmoji = 170 | possibleLeftEmoji[Math.floor(Math.random() * possibleLeftEmoji.length)]; 171 | 172 | setSelectedLeftEmoji(randomLeftEmoji); 173 | setLeftEmojiSelected(true); // If you click random on the left emoji, select that one 174 | } else { 175 | // Since we're selecting a new left emoji, clear out the right emoji 176 | var possibleEmoji: Array; 177 | 178 | // Pick a random emoji from all possible emoji 179 | possibleEmoji = getSupportedEmoji().filter( 180 | (codepoint) => codepoint !== selectedLeftEmoji 181 | ); 182 | 183 | const randomEmoji = 184 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)]; 185 | setSelectedLeftEmoji(randomEmoji); 186 | setSelectedRightEmoji(""); 187 | } 188 | }; 189 | 190 | /** 191 | * 👉 Handler when an emoji is selected from the right-hand list 192 | */ 193 | const handleRightEmojiClicked = (clickedEmoji: string) => { 194 | if (isMobile) { 195 | // Don't allow column unselect on mobile 196 | if (selectedRightEmoji !== clickedEmoji) { 197 | setSelectedRightEmoji(clickedEmoji); 198 | } 199 | } else { 200 | setSelectedRightEmoji( 201 | clickedEmoji === selectedRightEmoji ? "" : clickedEmoji 202 | ); 203 | } 204 | }; 205 | 206 | /** 207 | * 🎲 Handle right-hand randomize button clicked 208 | */ 209 | const handleRightEmojiRandomize = () => { 210 | const data = getEmojiData(selectedLeftEmoji); 211 | const possibleEmoji = Object.keys(data.combinations).filter( 212 | (codepoint) => codepoint !== selectedRightEmoji // Don't randomly choose the same right emoji 213 | ); 214 | 215 | const randomEmoji = 216 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)]; 217 | 218 | setSelectedRightEmoji(randomEmoji); 219 | 220 | if (isMobile) { 221 | setLeftEmojiSelected(false); 222 | } 223 | }; 224 | 225 | /** 226 | * 🎲 Handle full randomize button clicked 227 | */ 228 | const handleFullEmojiRandomize = () => { 229 | const knownSupportedEmoji = getSupportedEmoji(); 230 | const randomLeftEmoji = 231 | knownSupportedEmoji[ 232 | Math.floor(Math.random() * knownSupportedEmoji.length) 233 | ]; 234 | 235 | const data = getEmojiData(randomLeftEmoji); 236 | const possibleRightEmoji = Object.keys(data.combinations).filter( 237 | (codepoint) => codepoint !== randomLeftEmoji 238 | ); 239 | 240 | const randomRightEmoji = 241 | possibleRightEmoji[Math.floor(Math.random() * possibleRightEmoji.length)]; 242 | 243 | setSelectedLeftEmoji(randomLeftEmoji); 244 | setSelectedRightEmoji(randomRightEmoji); 245 | 246 | if (isMobile) { 247 | setMobileSearchResults([]); 248 | setMobileUuid(uuidv4()); 249 | } else { 250 | setLeftSearchResults([]); 251 | setLeftUuid(uuidv4()); 252 | setRightSearchResults([]); 253 | setRightUuid(uuidv4()); 254 | } 255 | }; 256 | 257 | /** 258 | * 💭 Helper function to open the bulk download menu 259 | */ 260 | const handleBulkImageDownloadMenuOpen = (event: React.MouseEvent) => { 261 | event.preventDefault(); 262 | setBulkDownloadMenu( 263 | bulkDownloadMenu === undefined 264 | ? { 265 | mouseX: event.clientX - 2, 266 | mouseY: event.clientY - 4, 267 | } 268 | : undefined 269 | ); 270 | }; 271 | 272 | /** 273 | * 💾 Handle bulk combination downloads 274 | */ 275 | const handleBulkImageDownload = async () => { 276 | try { 277 | // See: https://github.com/Stuk/jszip/issues/369 278 | // See: https://github.com/Stuk/jszip/issues/690 279 | const currentDate = new Date(); 280 | const dateWithOffset = new Date( 281 | currentDate.getTime() - currentDate.getTimezoneOffset() * 60000 282 | ); 283 | (JSZip as any).defaults.date = dateWithOffset; 284 | 285 | const zip = new JSZip(); 286 | const data = getEmojiData(selectedLeftEmoji); 287 | const photoZip = zip.folder(data.alt)!; 288 | 289 | setIsBulkDownloading(true); 290 | 291 | const combinations = Object.values(data.combinations) 292 | .flat() 293 | .filter((c) => c.isLatest); 294 | for (var i = 0; i < combinations.length; i++) { 295 | const combination = combinations[i]; 296 | const image = await fetch(combination.gStaticUrl); 297 | const imageBlob = await image.blob(); 298 | photoZip.file(`${combination.alt}.png`, imageBlob); 299 | } 300 | 301 | const archive = await zip.generateAsync({ type: "blob" }); 302 | saveAs(archive, data.alt); 303 | 304 | setBulkDownloadMenu(undefined); 305 | setIsBulkDownloading(false); 306 | } catch (e) { 307 | setBulkDownloadMenu(undefined); 308 | setIsBulkDownloading(false); 309 | } 310 | }; 311 | 312 | /** 313 | * 💾 Handle single combination downloads 314 | */ 315 | const handleImageDownload = () => { 316 | var combination = getEmojiData(selectedLeftEmoji).combinations[ 317 | selectedRightEmoji 318 | ].filter((c) => c.isLatest)[0]; 319 | 320 | saveAs(combination.gStaticUrl, combination.alt); 321 | }; 322 | 323 | /** 324 | * 💾 Handle single image copy-to-clipboard 325 | */ 326 | const handleImageCopy = async (url?: string) => { 327 | if (!url) { 328 | url = getEmojiData(selectedLeftEmoji).combinations[ 329 | selectedRightEmoji 330 | ].filter((c) => c.isLatest)[0].gStaticUrl; 331 | } else { 332 | setCombinationCopied(true); 333 | } 334 | 335 | const fetchImage = async () => { 336 | const image = await fetch(url); 337 | return await image.blob(); 338 | }; 339 | 340 | navigator.clipboard 341 | .write([ 342 | new ClipboardItem({ 343 | "image/png": fetchImage(), 344 | }), 345 | ]) 346 | .then(function () {}) 347 | .catch(function (error) { 348 | console.log(error); 349 | }); 350 | }; 351 | 352 | // See: https://caniuse.com/async-clipboard 353 | var hasClipboardSupport = "write" in navigator.clipboard; 354 | var middleList; 355 | var combination; 356 | var showOneCombo = false; 357 | 358 | if (isMobile) { 359 | if (selectedLeftEmoji === "" || selectedRightEmoji === "") { 360 | middleList =
; 361 | } else if (selectedMode === "combine") { 362 | showOneCombo = true; 363 | 364 | // First figure out what your "base" should be 365 | var baseEmoji = leftEmojiSelected 366 | ? selectedLeftEmoji 367 | : selectedRightEmoji; 368 | var otherEmoji = leftEmojiSelected 369 | ? selectedRightEmoji 370 | : selectedLeftEmoji; 371 | 372 | // Get the possible combinations for that base 373 | var combinations = getEmojiData(baseEmoji).combinations; 374 | 375 | // If we're switching out of the browse mode, the resulting leftover combination may no longer be valid 376 | // If so, generate a random pair and set the "other" appropriately 377 | if (!Object.keys(combinations).includes(otherEmoji)) { 378 | var possibleEmoji = Object.keys(combinations); 379 | var otherEmoji = 380 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)]; 381 | 382 | // Reset the "other" to a random valid combo 383 | if (leftEmojiSelected) { 384 | setSelectedRightEmoji(otherEmoji); 385 | } else { 386 | setSelectedLeftEmoji(otherEmoji); 387 | } 388 | } 389 | 390 | combination = combinations[otherEmoji].filter((c) => c.isLatest)[0]; 391 | 392 | middleList = ( 393 | 394 | {combination.alt} 395 | 396 | ); 397 | } else { 398 | // Browse combination browser on mobile 399 | var baseEmoji = leftEmojiSelected 400 | ? selectedLeftEmoji 401 | : selectedRightEmoji; 402 | middleList = Object.values(getEmojiData(baseEmoji).combinations) 403 | .flat() 404 | .filter((combination) => combination.isLatest) 405 | .sort((c1, c2) => c1.gBoardOrder - c2.gBoardOrder) 406 | .map((combination) => { 407 | return ( 408 | handleImageCopy(combination.gStaticUrl)} 410 | sx={{ 411 | p: 0.5, 412 | borderRadius: 2, 413 | "&:hover": { 414 | backgroundColor: (theme) => { 415 | return theme.palette.action.hover; 416 | }, 417 | }, 418 | }} 419 | > 420 | 421 | {combination.alt} 428 | 429 | 430 | ); 431 | }); 432 | } 433 | } else { 434 | // Neither are selected, show left list, empty middle list, and disable right list 435 | if (selectedLeftEmoji === "" && selectedRightEmoji === "") { 436 | middleList =
; 437 | } 438 | // Left emoji is selected, but not right, show the full list of combinations 439 | else if (selectedLeftEmoji !== "" && selectedRightEmoji === "") { 440 | middleList = Object.values(getEmojiData(selectedLeftEmoji).combinations) 441 | .flat() 442 | .filter((combination) => combination.isLatest) 443 | .sort((c1, c2) => c1.gBoardOrder - c2.gBoardOrder) 444 | .map((combination) => { 445 | return ( 446 | 447 | {combination.alt} 454 | 455 | ); 456 | }); 457 | } 458 | // Both are selected, show the single combo 459 | else { 460 | showOneCombo = true; 461 | combination = getEmojiData(selectedLeftEmoji).combinations[ 462 | selectedRightEmoji 463 | ].filter((c) => c.isLatest)[0]; 464 | 465 | middleList = ( 466 | 467 | {combination.alt} 468 | 469 | ); 470 | } 471 | } 472 | 473 | if (isMobile) { 474 | return ( 475 | 488 | {/* Entire Emoji Column */} 489 | 496 | {/* Top Section */} 497 | 716 | 717 | {/* Search */} 718 | 723 | 724 | {/* Emoji List */} 725 | 740 | 755 | 756 | 757 | {/* Full randomizer */} 758 | 768 | 775 | 🎲 776 | 777 | 778 | 779 | 780 | ); 781 | } 782 | 783 | return ( 784 | 795 | {/* Left Emoji Column */} 796 | 804 | {/* Left Search */} 805 | 812 | 813 | {/* Left Emoji List */} 814 | 829 | 835 | 836 | 837 | {/* Bulk Download Menu */} 838 | {selectedLeftEmoji !== "" ? ( 839 | setBulkDownloadMenu(undefined)} 842 | anchorReference="anchorPosition" 843 | anchorPosition={ 844 | bulkDownloadMenu !== undefined 845 | ? { 846 | top: bulkDownloadMenu.mouseY, 847 | left: bulkDownloadMenu.mouseX, 848 | } 849 | : undefined 850 | } 851 | > 852 | } 857 | sx={{ mx: 1 }} 858 | > 859 | Bulk Download 860 | 861 | 862 | ) : undefined} 863 | 864 | 865 | {/* Middle Emoji Column */} 866 | 876 | 883 | 🎲 884 | 885 | 886 | 898 | 911 | {middleList} 912 | {showOneCombo && hasClipboardSupport ? ( 913 | 916 | handleImageCopy()}> 917 | 918 | 919 | 920 | ) : null} 921 | 922 | {showOneCombo && !hasClipboardSupport ? ( 923 | 926 | 927 | 928 | 929 | 930 | ) : null} 931 | 932 | 933 | 934 | {/* Right Emoji Column */} 935 | 943 | {/* Right Search */} 944 | 952 | 953 | {/* Right Emoji List */} 954 | 969 | 975 | 976 | 977 | 978 | ); 979 | } 980 | -------------------------------------------------------------------------------- /assets/social.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------