├── src ├── data │ └── users.json ├── mock │ └── users.json ├── react-app-env.d.ts ├── interfaces │ └── ITwitterUser.ts ├── index.tsx ├── setupTests.ts ├── index.css ├── App.css ├── App.tsx └── components │ └── wheel │ ├── Wheel.tsx │ └── styles.css ├── .gitignore ├── tsconfig.json ├── public ├── index.html └── confetti.js ├── tsconfig.node.json ├── node ├── generate.ts └── fetchParticipants.ts ├── README.md └── package.json /src/data/users.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /src/mock/users.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/interfaces/ITwitterUser.ts: -------------------------------------------------------------------------------- 1 | export default interface ITwitterUser { 2 | username: string; 3 | handle: string; 4 | profileImage: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /.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 | /node_dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | width: 100%; 4 | height: 100%; 5 | position: absolute; 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 7 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | overflow: hidden; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Twitter Winner Picker 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "outDir": "node_dist", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "resolveJsonModule": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "declaration": true 16 | }, 17 | "include": ["node"], 18 | "exclude": ["node_modules", "node_dist"] 19 | } 20 | -------------------------------------------------------------------------------- /node/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { resolve } from 'path'; 3 | 4 | /** 5 | * Set the amount of mock-users to create 6 | */ 7 | const USERS_AMOUNT = 500; 8 | 9 | const profileImages = [ 10 | 'https://randomuser.me/api/portraits/med/men/18.jpg', 11 | 'https://randomuser.me/api/portraits/med/men/7.jpg', 12 | 'https://randomuser.me/api/portraits/med/women/48.jpg', 13 | 'https://randomuser.me/api/portraits/med/men/60.jpg', 14 | 'https://randomuser.me/api/portraits/med/men/98.jpg', 15 | 'https://randomuser.me/api/portraits/med/women/46.jpg', 16 | 'https://randomuser.me/api/portraits/med/men/71.jpg', 17 | 'https://randomuser.me/api/portraits/med/men/43.jpg', 18 | 'https://randomuser.me/api/portraits/med/women/43.jpg', 19 | 'https://randomuser.me/api/portraits/med/women/20.jpg', 20 | ]; 21 | 22 | const users = []; 23 | 24 | for (let i = 0; i < USERS_AMOUNT; i++) { 25 | const user = { 26 | username: `User Userson-${i}`, 27 | handle: `@user_name_${i}`, 28 | profileImage: profileImages[Math.floor(Math.random() * profileImages.length)], 29 | }; 30 | 31 | users.push(user); 32 | } 33 | 34 | fs.writeFileSync(resolve(__dirname, '../src/mock/users.json'), JSON.stringify(users, null, 2)); 35 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | width: 100%; 3 | height: 100%; 4 | position: absolute; 5 | text-align: center; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | transition: color 1s; 10 | } 11 | 12 | .winner-container { 13 | width: 100px; 14 | height: 100px; 15 | border-radius: 50%; 16 | position: absolute; 17 | top: 50%; 18 | left: 50%; 19 | z-index: 25; 20 | margin-top: -50px; 21 | margin-left: 290px; 22 | transition: 2.5s; 23 | background-size: cover; 24 | opacity: 0; 25 | } 26 | 27 | .show { 28 | width: 400px; 29 | height: 400px; 30 | margin-top: -200px; 31 | margin-left: -200px; 32 | opacity: 1; 33 | } 34 | 35 | .text-container { 36 | width: 400px; 37 | height: 100px; 38 | position: absolute; 39 | top: 70%; 40 | left: 50%; 41 | margin-left: -200px; 42 | transition: 2.5s; 43 | display: flex; 44 | flex-direction: column; 45 | justify-content: space-evenly; 46 | } 47 | 48 | .text { 49 | font-family: 'Lato', 'Quicksand', sans-serif; 50 | color: #2e2044; 51 | font-size: 38px; 52 | } 53 | 54 | .subtext { 55 | font-family: 'Lato', 'Quicksand', sans-serif; 56 | color: #2e2044; 57 | font-size: 32px; 58 | } 59 | 60 | canvas#canvas { 61 | display: block; 62 | background: #f4f5f7; 63 | transition: 2s; 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Twitter (Random) Winner Picker 2 | 3 | ![](https://i.imgur.com/MdtyGMQ.gif) 4 | 5 | This app was created as part of my [10K giveaway on Twitter](https://twitter.com/SimonHoiberg/status/1292831801648525314). 6 | 7 | The rules for the competition is: 8 | 9 | - Like 10 | - Retweet 11 | - Follow 12 | 13 | Feel free to use it in a similar competition. 14 | 15 | ### Generate mock data 16 | 17 | ```console 18 | npm run generate 19 | ``` 20 | 21 | Generate some mock-data for the wheel. 22 | The mock-data will be found in `src/mock/users.json`. 23 | Go to `node/generate.ts` to configure the generation. 24 | 25 | ### Fetch participants 26 | 27 | ```console 28 | npm run fetch 29 | ``` 30 | 31 | Fetch the participants from Twitter. 32 | This command will go through each of all your followers, and check if they both liked and retweeted. 33 | 34 | > :warning: This can potentially take long time, depending on how many followers you have. 35 | > For me, it took almost 3 days to go through them all. 36 | 37 | The data will be found in `src/data/users.json`. 38 | Go to `node/fetchParticipants.ts` to configure the twitter client. 39 | 40 | ### Start the wheel 41 | 42 | In the `App.tsx` file, you can choose to import either the `mock/users.json` or `data/users.json`. 43 | Start the wheel application with 44 | 45 | ```console 46 | npm start 47 | ``` 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-winner-picker", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "@types/jest": "^24.9.1", 10 | "@types/node": "^12.12.54", 11 | "@types/randomcolor": "^0.5.5", 12 | "@types/react": "^16.9.46", 13 | "@types/react-dom": "^16.9.8", 14 | "randomcolor": "^0.6.2", 15 | "react": "^16.13.1", 16 | "react-dom": "^16.13.1", 17 | "react-scripts": "3.4.1", 18 | "twitter-api-client": "0.1.9", 19 | "typescript": "^3.7.5" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "build:node": "tsc -p tsconfig.node.json", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject", 27 | "generate": "npm run build:node && node ./node_dist/generate.js", 28 | "fetch": "npm run build:node && node ./node_dist/fetchParticipants.js" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import ITwitterUser from './interfaces/ITwitterUser'; 3 | import Wheel from './components/wheel/Wheel'; 4 | import users from './mock/users.json'; 5 | import './App.css'; 6 | 7 | function App() { 8 | const [winner, setWinner] = useState(); 9 | const [showWinner, setShowWinner] = useState(false); 10 | 11 | useEffect(() => { 12 | if (!winner) { 13 | return; 14 | } 15 | 16 | setTimeout(() => { 17 | setShowWinner(true); 18 | }, 20000); 19 | }, [winner]); 20 | 21 | const handleSelectUser = (index: number) => { 22 | const winnerFromIndex = users[index]; 23 | setWinner(winnerFromIndex); 24 | }; 25 | 26 | const winnerStyle = winner ? { backgroundImage: `url(${winner.profileImage})` } : {}; 27 | 28 | return ( 29 |
30 | 31 |
32 |
33 |
{winner?.username}
34 |
{winner?.handle}
35 |
36 | 37 |
38 | ); 39 | } 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /src/components/wheel/Wheel.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, CSSProperties, useEffect } from 'react'; 2 | import randomColor from 'randomcolor'; 3 | import ITwitterUser from '../../interfaces/ITwitterUser'; 4 | import './styles.css'; 5 | 6 | interface IProps { 7 | twitterUsers: ITwitterUser[]; 8 | onSelectUser: (index: number) => void; 9 | hide?: boolean; 10 | } 11 | 12 | let colors: string[]; 13 | 14 | const Wheel: FC = (props) => { 15 | const [spinning, setSpinning] = useState(false); 16 | const [selectedUser, setSelectedUser] = useState(); 17 | const [visibleWheel, setVisibleWheel] = useState(0); 18 | 19 | useEffect(() => { 20 | if (selectedUser) { 21 | const i = setInterval(() => { 22 | const newVisibleWheel = Math.floor(Math.random() * chunks.length); 23 | setVisibleWheel(newVisibleWheel); 24 | }, 1500); 25 | 26 | setTimeout(() => { 27 | clearInterval(i); 28 | const lastChunk = Math.floor(selectedUser / 10); 29 | setVisibleWheel(lastChunk); 30 | }, 18000); 31 | } 32 | }, [selectedUser]); 33 | 34 | const chunkArray = (arr: ITwitterUser[], n: number): ITwitterUser[][] => 35 | arr.length ? [arr.slice(0, n), ...chunkArray(arr.slice(n), n)] : []; 36 | 37 | const chunks = chunkArray(props.twitterUsers, 10); 38 | 39 | if (!colors) { 40 | colors = randomColor({ luminosity: 'dark', count: chunks.length }); 41 | } 42 | 43 | const selectUser = () => { 44 | if (selectedUser) { 45 | return; 46 | } 47 | 48 | const newSelectedUser = Math.floor(Math.random() * props.twitterUsers.length); 49 | props.onSelectUser(newSelectedUser); 50 | setSelectedUser(newSelectedUser); 51 | setSpinning(true); 52 | }; 53 | 54 | const wheelVars = { 55 | '--nb-item': 10, 56 | '--selected-item': selectedUser, 57 | } as CSSProperties; 58 | 59 | const renderWheels = () => { 60 | return chunks.map((chunk, i) => ( 61 |
69 |
73 | {chunk.map((item, index) => ( 74 |
75 |
76 |
77 |
78 | {item.username} 79 |
80 |
@{item.handle}
81 |
82 |
86 |
87 |
88 | ))} 89 |
94 |
95 |
96 | )); 97 | }; 98 | 99 | return <>{renderWheels()}; 100 | }; 101 | 102 | export default Wheel; 103 | -------------------------------------------------------------------------------- /src/components/wheel/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --wheel-font: 'Lato', 'Quicksand', sans-serif; 3 | --wheel-size: 800px; 4 | --wheel-slice-spacing: 50px; 5 | --wheel-border-size: 5px; 6 | --wheel-color: #3a3a3a; 7 | --neutral-color: white; 8 | --PI: 3.14159265358979; 9 | --nb-item: 0; 10 | --item-nb: 0; 11 | --selected-item: 0; 12 | --nb-turn: 2; 13 | --spinning-duration: 20s; 14 | --reset-duration: 0.25s; 15 | } 16 | 17 | .wheel-container { 18 | display: block; 19 | position: absolute; 20 | box-sizing: content-box; 21 | width: calc(var(--wheel-size) + 2 * var(--wheel-border-size)); 22 | height: calc(var(--wheel-size) + 2 * var(--wheel-border-size)); 23 | padding: 3px; 24 | margin: auto; 25 | border-radius: 50%; 26 | user-select: none; 27 | transition: opacity 0.3s; 28 | } 29 | 30 | .wheel-container::before, 31 | .wheel-container::after { 32 | content: ''; 33 | display: block; 34 | position: absolute; 35 | height: 0; 36 | width: 0; 37 | top: 50%; 38 | transform: translateY(-50%); 39 | z-index: 2; 40 | border: solid transparent 20px; 41 | border-left-width: 0; 42 | } 43 | 44 | .wheel-container::before { 45 | right: 0px; 46 | border-right-color: inherit; 47 | } 48 | 49 | .wheel-container::after { 50 | right: -5px; 51 | border-right-color: var(--neutral-color); 52 | } 53 | 54 | .wheel { 55 | display: block; 56 | position: relative; 57 | box-sizing: content-box; 58 | margin: auto; 59 | width: var(--wheel-size); 60 | height: var(--wheel-size); 61 | overflow: hidden; 62 | border-radius: 50%; 63 | background-color: var(--wheel-color); 64 | transition: transform var(--reset-duration); 65 | transform: rotate(0deg); 66 | } 67 | 68 | .wheel.spinning { 69 | transition: transform var(--spinning-duration); 70 | transform: rotate( 71 | calc(var(--nb-turn) * 360deg + (-360deg * var(--selected-item) / var(--nb-item, 1))) 72 | ); 73 | } 74 | 75 | .wheel-middle { 76 | width: 100px; 77 | height: 100px; 78 | border-radius: 50%; 79 | position: absolute; 80 | left: 50%; 81 | top: 50%; 82 | z-index: 10; 83 | border: 10px solid var(--wheel-color); 84 | cursor: pointer; 85 | margin-left: -60px; 86 | margin-top: -60px; 87 | background-color: white; 88 | } 89 | 90 | .wheel::after { 91 | display: block; 92 | position: absolute; 93 | content: ''; 94 | background-color: var(--neutral-color); 95 | width: 25px; 96 | height: 25px; 97 | z-index: 2; 98 | top: 50%; 99 | left: 50%; 100 | transform: translate(-50%, -50%); 101 | border-radius: 50%; 102 | } 103 | 104 | .wheel-item { 105 | display: block; 106 | position: absolute; 107 | box-sizing: border-box; 108 | top: 50%; 109 | left: 50%; 110 | width: 50%; 111 | transform-origin: center left; 112 | transform: translateY(-50%) rotate(calc(var(--item-nb) * (360deg / var(--nb-item, 1)))); 113 | color: var(--neutral-color); 114 | text-align: right; 115 | padding: 0 25px 0 50px; 116 | font-family: var(--wheel-font); 117 | } 118 | 119 | .wheel-item:before { 120 | content: ' '; 121 | display: block; 122 | position: absolute; 123 | box-sizing: border-box; 124 | z-index: -1; 125 | width: 0; 126 | height: 0; 127 | top: 50%; 128 | left: 50%; 129 | transform: translate(-50%, -50%); 130 | padding-left: 0px; 131 | opacity: 0.25; 132 | 133 | --slice-max-width: calc(var(--PI) * var(--wheel-size) + var(--wheel-size) / 2); 134 | --slice-width: calc((var(--slice-max-width) / var(--nb-item)) - var(--wheel-slice-spacing)); 135 | border: solid transparent calc(var(--slice-width) / 2); 136 | border-left: solid transparent 0; 137 | 138 | border-right: solid var(--neutral-color) calc(var(--wheel-size) / 2); 139 | } 140 | 141 | .wheel-item-container { 142 | width: 70%; 143 | margin-left: 30%; 144 | display: flex; 145 | align-items: center; 146 | } 147 | 148 | .wheel-item-image { 149 | width: 30%; 150 | padding-bottom: 30%; 151 | border-radius: 50%; 152 | background-size: cover; 153 | background-position: center; 154 | } 155 | 156 | .wheel-item-text { 157 | width: 60%; 158 | margin-right: 25px; 159 | } 160 | 161 | .wheel-item-text div { 162 | width: 100%; 163 | } 164 | -------------------------------------------------------------------------------- /node/fetchParticipants.ts: -------------------------------------------------------------------------------- 1 | import TwitterClient from 'twitter-api-client'; 2 | import fs from 'fs'; 3 | import { resolve } from 'path'; 4 | import { promisify } from 'util'; 5 | 6 | const TWEET_ID = ''; 7 | const API_KEY = ''; 8 | const API_SECRET = ''; 9 | const ACCESS_TOKEN = ''; 10 | const ACCESS_TOKEN_SECRET = ''; 11 | 12 | const twitterClient = new TwitterClient({ 13 | apiKey: API_KEY, 14 | apiSecret: API_SECRET, 15 | accessToken: ACCESS_TOKEN, 16 | accessTokenSecret: ACCESS_TOKEN_SECRET, 17 | }); 18 | 19 | const writeFile = promisify(fs.writeFile); 20 | 21 | async function init() { 22 | await writeFile(resolve(__dirname, '../src/data/users.json'), '[]'); 23 | const followers = await getFollowers(); 24 | 25 | // We loop through all the users. 26 | // To avoid hitting the rate limit, we need to go through each one-by-one. 27 | for (const follower of followers) { 28 | console.log('Evaluation:', follower); 29 | 30 | const evaluate = async () => { 31 | const liked = await didLike(follower); 32 | const retweeted = await didRetweet(follower); 33 | 34 | if (liked && retweeted) { 35 | const [user] = await twitterClient.accountsAndUsers.usersLookup({ user_id: follower }); 36 | 37 | const smallUser = { 38 | username: user.name, 39 | handle: user.screen_name, 40 | profileImage: user.profile_image_url.replace('normal', '400x400'), 41 | }; 42 | 43 | const list = require('../src/data/users.json'); 44 | list.push(smallUser); 45 | 46 | await writeFile( 47 | resolve(__dirname, '../src/data/users.json'), 48 | JSON.stringify(list, null, 2), 49 | ); 50 | } 51 | 52 | console.log('Evaluation finished. Qualified: ', liked && retweeted); 53 | }; 54 | 55 | try { 56 | await evaluate(); 57 | } catch (error) { 58 | // If we hit a rate limit, we pause for 15 minutes and continue 59 | if (error.statusCode === 429) { 60 | console.log('Rate limit exceeded. Pausing for 15 minutes.'); 61 | await new Promise((r) => setTimeout(r, 5 * 60 * 1000)); 62 | console.log('10 minutes to go...'); 63 | await new Promise((r) => setTimeout(r, 5 * 60 * 1000)); 64 | console.log('5 minutes to go...'); 65 | await new Promise((r) => setTimeout(r, 5.5 * 60 * 1000)); 66 | console.log('Continuing...'); 67 | 68 | try { 69 | await evaluate(); 70 | } catch (error) { 71 | console.log('Evalution failed. Continuing...'); 72 | } 73 | } else { 74 | console.log('Evalution failed. Continuing...'); 75 | } 76 | } 77 | 78 | console.log('----------'); 79 | 80 | await new Promise((r) => setTimeout(r, 3000)); 81 | } 82 | } 83 | 84 | /** 85 | * Get all followers 86 | * @param cursor 87 | */ 88 | async function getFollowers(cursor?: string) { 89 | const params = cursor ? { cursor } : {}; 90 | 91 | const followerRequest = await twitterClient.accountsAndUsers.followersIds({ 92 | stringify_ids: true, 93 | ...params, 94 | }); 95 | 96 | if (!followerRequest) { 97 | return []; 98 | } 99 | 100 | let followers = (followerRequest.ids as any) as string[]; 101 | 102 | if (followerRequest.next_cursor_str !== '0') { 103 | const nextFollowers = await getFollowers(followerRequest.next_cursor_str); 104 | 105 | if (nextFollowers) { 106 | followers = [...followers, ...nextFollowers]; 107 | } 108 | } 109 | 110 | return followers; 111 | } 112 | 113 | /** 114 | * Returns true if the user liked the tweet 115 | * @param userID 116 | */ 117 | async function didLike(userID: string) { 118 | const likes = await twitterClient.tweets.favoritesList({ 119 | user_id: userID, 120 | max_id: TWEET_ID, 121 | count: 1, 122 | }); 123 | 124 | const [like] = likes; 125 | 126 | return like?.id_str === TWEET_ID; 127 | } 128 | 129 | /** 130 | * Return true if the user retweetet the tweet 131 | * @param userID 132 | */ 133 | async function didRetweet(userID: string) { 134 | const retweets = await twitterClient.tweets.statusesUserTimeline({ 135 | user_id: userID, 136 | count: 200, 137 | include_rts: true, 138 | exclude_replies: true, 139 | }); 140 | 141 | const isQuoted = retweets.some((rt) => rt.quoted_status?.id_str === TWEET_ID); 142 | const isRetweeted = retweets.some((rt) => rt.retweeted_status?.id_str === TWEET_ID); 143 | 144 | return isQuoted || isRetweeted; 145 | } 146 | 147 | init(); 148 | -------------------------------------------------------------------------------- /public/confetti.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | window.addEventListener('load', function () { 4 | var canvas = document.getElementById('canvas'); 5 | 6 | if (!canvas || !canvas.getContext) { 7 | return false; 8 | } 9 | 10 | /******************** 11 | Random Number 12 | ********************/ 13 | 14 | function rand(min, max) { 15 | return Math.floor(Math.random() * (max - min + 1) + min); 16 | } 17 | 18 | /******************** 19 | Var 20 | ********************/ 21 | 22 | var ctx = canvas.getContext('2d'); 23 | var X = (canvas.width = window.innerWidth); 24 | var Y = (canvas.height = window.innerHeight); 25 | var mouseX = X / 2; 26 | var mouseY = Y / 2; 27 | var shapes = []; 28 | var shapeNum = 1000; 29 | var xNum = 2; 30 | var yNum = 5; 31 | 32 | if (X < 768) { 33 | shapeNum = 500; 34 | xNum = 1; 35 | yNum = 4; 36 | } 37 | 38 | /******************** 39 | Animation 40 | ********************/ 41 | 42 | window.requestAnimationFrame = 43 | window.requestAnimationFrame || 44 | window.mozRequestAnimationFrame || 45 | window.webkitRequestAnimationFrame || 46 | window.msRequestAnimationFrame || 47 | function (cb) { 48 | setTimeout(cb, 17); 49 | }; 50 | 51 | /******************** 52 | Shape 53 | ********************/ 54 | 55 | function Shape(ctx, x, y) { 56 | this.ctx = ctx; 57 | this.init(x, y); 58 | } 59 | 60 | Shape.prototype.init = function (x, y) { 61 | this.x = x; 62 | this.y = y; 63 | this.r = 1; 64 | this.c = rand(0, 360); 65 | this.a = rand(0, 360); 66 | this.rad = (this.a * Math.PI) / 180; 67 | this.inA = Math.random(); 68 | this.inR = Math.random() * Math.random() * Math.random(); 69 | this.v = { 70 | x: Math.sin(this.rad) * xNum, 71 | y: Math.cos(this.rad) * yNum, 72 | }; 73 | this.ga = Math.random(); 74 | }; 75 | 76 | Shape.prototype.draw = function () { 77 | var ctx = this.ctx; 78 | ctx.save(); 79 | ctx.fillStyle = 'hsl(' + this.c + ', ' + '80%, 80%)'; 80 | ctx.globalCompositeOperation = 'xor'; 81 | ctx.globalAlpha = this.ga; 82 | ctx.translate(this.x + this.r / 2, this.y + this.r / 2); 83 | ctx.rotate(this.rad * 1.5); 84 | ctx.scale(Math.sin(this.rad), Math.cos(this.rad)); 85 | ctx.translate(-this.x - this.r / 2, -this.y - this.r / 2); 86 | ctx.beginPath(); 87 | ctx.rect(this.x, this.y, this.r, this.r); 88 | ctx.fill(); 89 | ctx.restore(); 90 | }; 91 | 92 | Shape.prototype.resize = function () { 93 | this.x = X / 2; 94 | this.y = Y / 2; 95 | }; 96 | 97 | Shape.prototype.updatePosition = function () { 98 | this.v.y += 0.01; 99 | this.x += this.v.x; 100 | this.y += this.v.y; 101 | }; 102 | 103 | Shape.prototype.wrapPosition = function () { 104 | if (this.y - this.r > Y) { 105 | this.init(X / 2, Y / 2); 106 | } 107 | }; 108 | 109 | Shape.prototype.updateParams = function (i) { 110 | if (i % 2 === 0) { 111 | this.a += this.inA; 112 | } else { 113 | this.a -= this.inA; 114 | } 115 | this.rad = (this.a * Math.PI) / 180; 116 | this.r += this.inR; 117 | }; 118 | 119 | Shape.prototype.render = function (i) { 120 | this.updateParams(i); 121 | this.updatePosition(); 122 | this.wrapPosition(); 123 | this.draw(); 124 | }; 125 | 126 | for (var i = 0; i < 1; i++) { 127 | var s = new Shape(ctx, X / 2, Y / 2); 128 | shapes.push(s); 129 | } 130 | 131 | var clearId = setInterval(function () { 132 | var s = new Shape(ctx, X / 2, Y / 2); 133 | shapes.push(s); 134 | if (shapes.length > 1000) { 135 | clearInterval(clearId); 136 | } 137 | }, 60); 138 | 139 | /******************** 140 | Render 141 | ********************/ 142 | 143 | function render() { 144 | ctx.clearRect(0, 0, X, Y); 145 | for (var i = 0; i < shapes.length; i++) { 146 | shapes[i].render(i); 147 | } 148 | requestAnimationFrame(render); 149 | } 150 | 151 | render(); 152 | 153 | /******************** 154 | Event 155 | ********************/ 156 | 157 | function onResize() { 158 | X = canvas.width = window.innerWidth; 159 | Y = canvas.height = window.innerHeight; 160 | if (X < 768) { 161 | shapeNum = 500; 162 | xNum = 1; 163 | yNum = 4; 164 | } else { 165 | shapeNum = 1000; 166 | xNum = 2; 167 | yNum = 5; 168 | } 169 | for (var i = 0; i < shapes.length; i++) { 170 | shapes[i].resize(); 171 | } 172 | } 173 | 174 | window.addEventListener('resize', function () { 175 | onResize(); 176 | }); 177 | 178 | canvas.addEventListener( 179 | 'wheel', 180 | function (e) { 181 | for (var i = 0; i < shapes.length; i++) {} 182 | }, 183 | false, 184 | ); 185 | }); 186 | // Author 187 | console.log( 188 | 'File Name / confetti.js\nCreated Date / July 08, 2020\nAuthor / Toshiya Marukubo\nTwitter / https://twitter.com/toshiyamarukubo', 189 | ); 190 | })(); --------------------------------------------------------------------------------