├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── LICENSE.txt
├── Procfile
├── README.md
├── app.json
├── app
├── package.json
├── public
│ ├── card.png
│ ├── favicon.png
│ ├── index.html
│ └── robots.txt
├── src
│ ├── components
│ │ ├── App.tsx
│ │ ├── Event.tsx
│ │ ├── EventList.tsx
│ │ ├── EventVisualization.tsx
│ │ ├── Footer.tsx
│ │ ├── Masthead.tsx
│ │ ├── MusicGenerator.tsx
│ │ └── ToggleModeButton.tsx
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ ├── react-reveal.d.ts
│ ├── serviceWorker.ts
│ └── theme.ts
└── tsconfig.json
├── package.json
├── server
├── package.json
├── src
│ ├── config.ts
│ └── index.ts
└── tsconfig.json
└── yarn.lock
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | - push
5 | - pull_request
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - uses: actions/setup-node@v1
13 | - run: yarn install --frozen-lockfile
14 | - run: yarn build
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | node_modules/
3 | .DS_Store
4 | .env
5 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Victor Truong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: yarn start
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Listen to music generated from new tweets on Twitter.
6 |
7 |
8 | 
9 |
10 | Iora is inspired by [@debugger22](https://github.com/debugger22)’s [GitHub Audio](https://github.com/debugger22/github-audio). I had it playing in the background at one point while working on this. 😄
11 |
12 | ## How it works
13 |
14 | Each incoming tweet is converted into a note and duration pair. There are five different types a tweet can be classified as: new tweet, retweet, reply, poll, and media (images, videos, GIFs), and a note is assigned according to that. The duration is calculated by dividing the tweet length by 70 (the amount of characters per fourth of a tweet at max length).
15 |
16 | ## Deploy
17 |
18 | [](https://heroku.com/deploy)
19 |
20 | ## Setup
21 |
22 | Iora is composed of three components:
23 |
24 | 1. A **Twitter app** registered through the Twitter Developer Portal, which is used to access data from the platform
25 | 2. A **Node.js WebSockets server** which interfaces with Twitter’s stream API via a long-lived HTTP connection. Received messages are re-broadcasted to all connected clients as WebSocket messages. This is necessary because Twitter restricts apps to one concurrent connection.
26 | 3. A **React.js frontend** for music generation and displaying received data
27 |
28 | ### Creating the Twitter app
29 |
30 | 1. Go to Twitter’s [Developer Portal](https://developer.twitter.com/en/portal/dashboard) and create a new app. Make sure the app is compatible with Twitter’s API V2.
31 | 2. Go to **Keys and tokens** and note down the value of **Bearer token**.
32 |
33 | ### Environment variables
34 |
35 | Here are all the variables you need to set up on the server, with hints.
36 |
37 | ```bash
38 | # Port to run the server on.
39 | PORT=3000
40 |
41 | # The URL to prepend to all built assets that'll be served.
42 | PUBLIC_URL=https://iora.live
43 | # Obtained from the Twitter Developer Portal.
44 | TWITTER_BEARER_TOKEN=AAAA…
45 | ```
46 |
47 | ### Starting the server
48 |
49 | _This section is only relevent to you if you’ve decided to run Iora on a platform other than Heroku._
50 |
51 | ```bash
52 | git clone https://github.com/ifvictr/iora
53 | cd iora
54 | # Install dependencies
55 | yarn
56 | # Start Iora in production! This will build the source files and then run them.
57 | yarn build
58 | yarn start
59 | # Or, if you need to run it in development mode instead. This will start both the backend and frontend and run them concurrently.
60 | yarn dev
61 | ```
62 |
63 | After you’ve followed all the above steps, you should see something like this in the console:
64 |
65 | ```bash
66 | Starting Iora…
67 | Listening on port 3000
68 | Connected to Twitter stream
69 | ```
70 |
71 | ## License
72 |
73 | [MIT License](LICENSE.txt)
74 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iora",
3 | "description": "Listen to music generated from new tweets on Twitter",
4 | "logo": "https://files.ifvictr.com/2020/08/iora.png",
5 | "repository": "https://github.com/ifvictr/iora",
6 | "buildpacks": [
7 | {
8 | "url": "heroku/nodejs"
9 | }
10 | ],
11 | "env": {
12 | "PUBLIC_URL": {
13 | "description": "The URL to serve the React app’s resources from. Will be prepended to all built assets.",
14 | "required": false
15 | },
16 | "TWITTER_BEARER_TOKEN": {
17 | "description": "Bearer token obtained from an app created through Twitter’s Developer portal. Must have V2 access."
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@iora/app",
3 | "version": "1.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "react-scripts build",
8 | "dev": "react-scripts start",
9 | "eject": "react-scripts eject",
10 | "test": "react-scripts test"
11 | },
12 | "browserslist": {
13 | "production": [
14 | ">0.2%",
15 | "not dead",
16 | "not op_mini all"
17 | ],
18 | "development": [
19 | "last 1 chrome version",
20 | "last 1 firefox version",
21 | "last 1 safari version"
22 | ]
23 | },
24 | "eslintConfig": {
25 | "extends": "react-app"
26 | },
27 | "dependencies": {
28 | "d3": "^5.16.0",
29 | "react": "^16.13.1",
30 | "react-dom": "^16.13.1",
31 | "react-reveal": "^1.2.2",
32 | "react-scripts": "3.4.3",
33 | "theme-ui": "^0.3.1",
34 | "tone": "^14.7.39",
35 | "use-socketio": "^2.0.3"
36 | },
37 | "devDependencies": {
38 | "@types/d3": "^5.7.2",
39 | "@types/node": "^14.0.27",
40 | "@types/react": "^16.9.46",
41 | "@types/react-dom": "^16.9.8",
42 | "@types/theme-ui": "^0.3.6",
43 | "typescript": "^3.9.7"
44 | },
45 | "proxy": "http://localhost:3001"
46 | }
47 |
--------------------------------------------------------------------------------
/app/public/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ifvictr/iora/c53e8385080e121457aa748d5d89b40a58c9ceff/app/public/card.png
--------------------------------------------------------------------------------
/app/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ifvictr/iora/c53e8385080e121457aa748d5d89b40a58c9ceff/app/public/favicon.png
--------------------------------------------------------------------------------
/app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | iora: Listen to music generated from new tweets on Twitter
10 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/app/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container, ThemeProvider } from 'theme-ui'
3 | import { SocketIOProvider } from 'use-socketio'
4 | import theme from '../theme'
5 | import EventList from './EventList'
6 | import EventVisualization from './EventVisualization'
7 | import Footer from './Footer'
8 | import Masthead from './Masthead'
9 | import MusicGenerator from './MusicGenerator'
10 | import ToggleModeButton from './ToggleModeButton'
11 |
12 | const App = () => {
13 | return (
14 |
15 |
22 |
23 |
24 |
25 |
26 |
27 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default App
44 |
--------------------------------------------------------------------------------
/app/src/components/Event.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import { Avatar, Box, Flex, Link, Text, useColorMode } from 'theme-ui'
3 | import theme from '../theme'
4 |
5 | export interface Payload {
6 | data: Tweet
7 | includes: {
8 | media?: Media[]
9 | polls?: Poll[]
10 | tweets?: Tweet[]
11 | users: User[]
12 | }
13 | errors?: TwitterAPIError[]
14 | }
15 |
16 | export interface TwitterAPIError {
17 | detail: string
18 | parameter: string
19 | resource_type: string
20 | title: string
21 | type: string
22 | value: string
23 | }
24 |
25 | export interface Media {
26 | media_key: string
27 | type: 'animated_gif' | 'photo' | 'video'
28 | }
29 |
30 | export interface Poll {
31 | id: string
32 | }
33 |
34 | export interface Tweet {
35 | author_id: string
36 | created_at: string
37 | id: string
38 | in_reply_to_user_id: string
39 | lang: string
40 | possibly_sensitive: boolean
41 | public_metrics: {
42 | like_count: number
43 | quote_count: number
44 | reply_count: number
45 | retweet_count: number
46 | }
47 | referenced_tweets?: TweetReference[]
48 | text: string
49 | }
50 |
51 | export interface TweetReference {
52 | id: string
53 | type: 'replied_to' | 'retweeted' | 'quoted'
54 | }
55 |
56 | export interface User {
57 | id: string
58 | name: string
59 | profile_image_url: string
60 | public_metrics: {
61 | followers_count: number
62 | following_count: number
63 | listed_count: number
64 | tweet_count: number
65 | }
66 | username: string
67 | verified: boolean
68 | }
69 |
70 | const RETWEET_PATTERN = /^RT @(.+):\s/
71 | export const getEventType = (payload: Payload): EventType => {
72 | const isRetweet = RETWEET_PATTERN.test(payload.data.text)
73 |
74 | if ('polls' in payload.includes && !isRetweet) {
75 | return 'poll'
76 | } else if ('media' in payload.includes && !isRetweet) {
77 | return 'media'
78 | } else if (!!payload.data.in_reply_to_user_id) {
79 | return 'reply'
80 | } else if (isRetweet) {
81 | return 'retweet'
82 | }
83 |
84 | return 'tweet'
85 | }
86 |
87 | export type EventType = 'media' | 'poll' | 'reply' | 'retweet' | 'tweet'
88 |
89 | export interface EventInfo {
90 | emoji: string
91 | color: string
92 | description: (payload: Payload) => string | React.ReactElement
93 | rawDescription: (payload: Payload) => string
94 | transformText: (payload: Payload) => string
95 | }
96 |
97 | export const EVENTS: Record = {
98 | media: {
99 | emoji: '🖼️',
100 | color: theme.colors.orange,
101 | description: payload => {
102 | const { type } = payload.includes.media![0]
103 | return `posted a ${type === 'animated_gif' ? 'GIF' : type}`
104 | },
105 | rawDescription: payload => {
106 | const { type } = payload.includes.media![0]
107 | return `posted a ${type === 'animated_gif' ? 'GIF' : type}`
108 | },
109 | transformText: payload => payload.data.text
110 | },
111 | poll: {
112 | emoji: '🗳️',
113 | color: theme.colors.purple,
114 | description: () => 'started a poll',
115 | rawDescription: () => 'started a poll',
116 | transformText: payload => payload.data.text
117 | },
118 | reply: {
119 | emoji: '💬',
120 | color: 'transparent',
121 | description: payload => {
122 | const recipient = payload.includes.users.find(
123 | user => user.id === payload.data.in_reply_to_user_id
124 | )
125 | return (
126 | <>
127 | replied to{' '}
128 | {!!recipient ? (
129 | <>
130 | {
135 | e.stopPropagation()
136 | }}
137 | >
138 | {recipient.name}
139 |
140 | >
141 | ) : (
142 | 'a tweet'
143 | )}
144 | >
145 | )
146 | },
147 | rawDescription: payload => {
148 | const recipient = payload.includes.users.find(
149 | user => user.id === payload.data.in_reply_to_user_id
150 | )
151 | return (
152 | 'replied to ' + (!!recipient ? `@${recipient.username}` : 'a tweet')
153 | )
154 | },
155 | transformText: payload => payload.data.text
156 | },
157 | retweet: {
158 | emoji: '🔁',
159 | color: theme.colors.green,
160 | description: payload => {
161 | const retweetReference = payload.data.referenced_tweets!.find(
162 | referencedTweet => referencedTweet.type === 'retweeted'
163 | )
164 | const originalTweet = payload.includes.tweets?.find(
165 | includedTweet => includedTweet.id === retweetReference?.id
166 | )
167 | const author = payload.includes.users.find(
168 | user => user.id === originalTweet?.author_id
169 | )
170 | return (
171 | <>
172 | retweeted{' '}
173 | {!!author ? (
174 | <>
175 | {
180 | e.stopPropagation()
181 | }}
182 | >
183 | {author.name}
184 |
185 | ’s tweet
186 | >
187 | ) : (
188 | 'a tweet'
189 | )}
190 | >
191 | )
192 | },
193 | rawDescription: payload => {
194 | const retweetReference = payload.data.referenced_tweets!.find(
195 | referencedTweet => referencedTweet.type === 'retweeted'
196 | )
197 | const originalTweet = payload.includes.tweets?.find(
198 | includedTweet => includedTweet.id === retweetReference?.id
199 | )
200 | const author = payload.includes.users.find(
201 | user => user.id === originalTweet?.author_id
202 | )
203 | return `retweeted ${!!author ? `@${author.username}’s tweet` : 'a tweet'}`
204 | },
205 | transformText: payload => payload.data.text.replace(RETWEET_PATTERN, '') // Remove the RT @user prefix
206 | },
207 | tweet: {
208 | emoji: '📢',
209 | color: '#1da0f2',
210 | description: () => 'tweeted',
211 | rawDescription: () => 'tweeted',
212 | transformText: payload => payload.data.text
213 | }
214 | }
215 |
216 | interface EventProps {
217 | type: EventType
218 | data: Payload
219 | }
220 |
221 | const Event = ({ type, data: tweet }: EventProps) => {
222 | const [colorMode] = useColorMode()
223 |
224 | const { color, description, transformText } = EVENTS[type]
225 | const sender = tweet.includes.users.find(
226 | user => user.id === tweet.data.author_id
227 | ) as User
228 |
229 | const openTweet = useCallback(() => {
230 | window.open(
231 | `https://twitter.com/${sender.username}/status/${tweet.data.id}`,
232 | '_blank'
233 | )
234 | }, [sender.username, tweet.data.id])
235 |
236 | return (
237 |
253 |
259 |
260 |
267 |
268 |
269 |
270 | {
275 | e.stopPropagation()
276 | }}
277 | >
278 | {sender.name}
279 | {' '}
280 | {description(tweet)}
281 |
282 |
283 | {transformText(tweet)}
284 |
285 |
286 |
287 |
288 | )
289 | }
290 |
291 | export default Event
292 |
--------------------------------------------------------------------------------
/app/src/components/EventList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react'
2 | import { Slide } from 'react-reveal'
3 | import { Box, Heading, SxStyleProp, useColorMode } from 'theme-ui'
4 | import { useSocket } from 'use-socketio'
5 | import Event, { Payload, getEventType } from './Event'
6 |
7 | export interface EventListProps {
8 | maxEvents?: number
9 | sx?: SxStyleProp
10 | }
11 |
12 | const EventList = ({ maxEvents = 50, sx, ...props }: EventListProps) => {
13 | const [isConnected, setConnected] = useState(false)
14 | const [payloads, setPayloads] = useState([])
15 | const payloadQueueRef = useRef([])
16 | const [colorMode] = useColorMode()
17 |
18 | useEffect(() => {
19 | const addIntervalId = setInterval(() => {
20 | if (payloadQueueRef.current.length === 0) {
21 | return
22 | }
23 |
24 | const nextPayload = payloadQueueRef.current.shift() as Payload
25 | // Don't let the total saved payloads exceed the maximum
26 | setPayloads([nextPayload, ...payloads].slice(0, maxEvents))
27 | }, 500)
28 |
29 | return () => {
30 | clearInterval(addIntervalId)
31 | }
32 | }, [maxEvents, payloads])
33 |
34 | useSocket('connect', () => {
35 | setConnected(true)
36 | })
37 | useSocket('connect_error', () => {
38 | setConnected(false)
39 | })
40 | useSocket('stream_close', () => {
41 | setConnected(false)
42 | })
43 |
44 | useSocket('tweet', data => {
45 | // Fix visual indicator when `connect` occasionally fails to fire
46 | if (!isConnected) {
47 | setConnected(true)
48 | }
49 |
50 | const newPayload = JSON.parse(data) as Payload
51 | // Adhere to the payload queue's cap
52 | if (payloadQueueRef.current.length < maxEvents) {
53 | payloadQueueRef.current.push(newPayload)
54 | }
55 | })
56 |
57 | return (
58 |
95 |
105 |
106 | Live from Twitter
107 |
108 |
109 | {payloads.length !== 0 && (
110 |
114 | {payloads.map(payload => (
115 |
116 |
117 |
118 | ))}
119 |
120 | )}
121 |
122 | )
123 | }
124 |
125 | export default EventList
126 |
--------------------------------------------------------------------------------
/app/src/components/EventVisualization.tsx:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import React, { useRef } from 'react'
3 | import { Box, SxStyleProp, useColorMode } from 'theme-ui'
4 | import { useSocket } from 'use-socketio'
5 | import { EVENTS, Payload, User, getEventType } from './Event'
6 |
7 | interface SVGGroupProps {
8 | radius: number
9 | duration: number
10 | ringRadius: number
11 | ringDuration: number
12 | }
13 |
14 | const getFollowerMultiplier = (followerCount: number) => {
15 | if (followerCount >= Math.pow(10, 7)) {
16 | // 10 million followers and above
17 | return 6
18 | } else if (followerCount >= Math.pow(10, 6)) {
19 | // Between 1 million followers and 9,999,999 followers
20 | return 4
21 | } else if (followerCount >= Math.pow(10, 5)) {
22 | // Between 100k and 999,999 followers
23 | return 2
24 | } else if (followerCount >= Math.pow(10, 4)) {
25 | // Between 10k and 99,999 followers
26 | return 1.25
27 | }
28 |
29 | return 1
30 | }
31 |
32 | const getSVGGroupProps = (payload: Payload) => {
33 | const type = getEventType(payload)
34 | const isNewTweet = type === 'tweet'
35 | const sender = payload.includes.users.find(
36 | user => user.id === payload.data.author_id
37 | ) as User
38 | const followerMultipler = getFollowerMultiplier(
39 | sender.public_metrics.followers_count
40 | )
41 |
42 | const values: SVGGroupProps = {
43 | radius: (isNewTweet ? 30 : 15) * followerMultipler,
44 | duration: isNewTweet ? 15000 : 7500,
45 | ringRadius: 75 * followerMultipler,
46 | ringDuration: isNewTweet ? 4000 : 2000
47 | }
48 |
49 | return values
50 | }
51 |
52 | interface EventVisualizationProps {
53 | sx?: SxStyleProp
54 | }
55 |
56 | const EventVisualization = ({ sx, ...props }: EventVisualizationProps) => {
57 | const d3Ref = useRef(null)
58 | const [colorMode] = useColorMode()
59 |
60 | useSocket('tweet', data => {
61 | if (!d3Ref.current) {
62 | return
63 | }
64 |
65 | const newPayload = JSON.parse(data) as Payload
66 | const type = getEventType(newPayload)
67 | const values = getSVGGroupProps(newPayload)
68 |
69 | const svg = d3.select(d3Ref.current)
70 | const container = svg.append('g')
71 | container
72 | .attr(
73 | 'fill',
74 | EVENTS[type].color !== 'transparent' ? EVENTS[type].color : '#e6ecf0'
75 | )
76 | .attr(
77 | 'transform',
78 | `translate(${Math.random() * window.innerWidth}, ${
79 | Math.random() * window.innerHeight
80 | })`
81 | )
82 | .transition()
83 | .delay(5000)
84 | .style('opacity', 0)
85 | .ease(Math.sqrt)
86 | .duration(values.ringDuration)
87 | .remove()
88 |
89 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
90 | const ring = container
91 | .append('circle')
92 | .attr('r', values.radius)
93 | .attr('stroke', 'none')
94 | .transition()
95 | .attr('r', values.ringRadius)
96 | .style('opacity', 0)
97 | .ease(Math.sqrt)
98 | .duration(values.ringDuration)
99 | .remove()
100 |
101 | const circle = container.append('circle')
102 | circle
103 | .attr('r', values.radius)
104 | .style('opacity', 0.5)
105 | .transition()
106 | .style('opacity', 0)
107 | .ease(Math.sqrt)
108 | .duration(values.duration)
109 | .remove()
110 |
111 | const link = container.append('a')
112 | link
113 | .attr(
114 | 'href',
115 | `https://twitter.com/${newPayload.includes.users[0].username}/status/${newPayload.data.id}`
116 | )
117 | .attr('target', '_blank')
118 | .transition()
119 | .delay(2500)
120 | .style('opacity', 0)
121 | .ease(Math.sqrt)
122 | .duration(values.duration - 2500)
123 | .remove()
124 |
125 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
126 | const description = link
127 | .append('text')
128 | .text(
129 | `${EVENTS[type].emoji} @${
130 | newPayload.includes.users[0].username
131 | } ${EVENTS[type].rawDescription(newPayload)}`
132 | )
133 | .attr('text-anchor', 'middle')
134 | })
135 |
136 | return (
137 | theme.colors.text,
152 | transition: 'fill 0.5s ease',
153 | ':hover': {
154 | textDecoration: 'underline'
155 | }
156 | },
157 | ...sx
158 | }}
159 | ref={d3Ref}
160 | {...props}
161 | />
162 | )
163 | }
164 |
165 | export default EventVisualization
166 |
--------------------------------------------------------------------------------
/app/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container, Link, Text } from 'theme-ui'
3 |
4 | const Footer = () => (
5 |
6 |
7 | Made by{' '}
8 |
13 | @ifvictr
14 |
15 | . See the code behind this on{' '}
16 |
21 | GitHub
22 |
23 | .
24 |
25 |
26 | )
27 |
28 | export default Footer
29 |
--------------------------------------------------------------------------------
/app/src/components/Masthead.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container, Heading } from 'theme-ui'
3 |
4 | const Masthead = () => (
5 |
6 |
19 | iora
20 |
21 |
22 | Listen to music generated from new tweets on Twitter.
23 |
24 |
25 | )
26 |
27 | export default Masthead
28 |
--------------------------------------------------------------------------------
/app/src/components/MusicGenerator.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react'
2 | import { Flex, useColorMode } from 'theme-ui'
3 | import * as tone from 'tone'
4 | import { useSocket } from 'use-socketio'
5 | import { EventType, Payload, getEventType } from './Event'
6 |
7 | const NOTES: Record = {
8 | media: 'Eb2',
9 | poll: 'Db2',
10 | reply: 'Cb2',
11 | retweet: 'Bb2',
12 | tweet: 'Ab2'
13 | }
14 |
15 | interface Beat {
16 | note: tone.Unit.Frequency
17 | duration: number
18 | }
19 |
20 | const CHARS_PER_FOURTH = 70
21 | const getBeat = (payload: Payload): Beat => {
22 | // TODO: Get better notes/sequences with the variables in the payload
23 | const type = getEventType(payload)
24 | const tweetLength = payload.data.text.length
25 | return {
26 | note: NOTES[type],
27 | duration: Math.ceil(tweetLength / CHARS_PER_FOURTH)
28 | }
29 | }
30 |
31 | const MusicGenerator = () => {
32 | const synthRef = useRef(new tone.Synth().toDestination())
33 | const beatQueueRef = useRef([])
34 | const [isReady, setReady] = useState(false)
35 | const [colorMode] = useColorMode()
36 |
37 | const checkForBeat = () => {
38 | if (!isReady) {
39 | return
40 | }
41 |
42 | if (beatQueueRef.current.length === 0) {
43 | setTimeout(checkForBeat, 1000) // Check for a new beat in one second
44 | return
45 | }
46 |
47 | const nextBeat = beatQueueRef.current.shift() as Beat
48 | synthRef.current.triggerAttackRelease(nextBeat.note, nextBeat.duration)
49 |
50 | // Check for another beat to play after this one's time is up
51 | setTimeout(checkForBeat, nextBeat.duration * 250)
52 | }
53 |
54 | // Get the user to click so we can start playing audio
55 | useEffect(() => {
56 | document.addEventListener(
57 | 'click',
58 | async () => {
59 | setReady(true)
60 | await tone.start()
61 | },
62 | { once: true }
63 | )
64 | }, [])
65 |
66 | useEffect(checkForBeat, [isReady])
67 |
68 | useSocket('tweet', data => {
69 | if (!isReady) {
70 | return
71 | }
72 |
73 | const newPayload = JSON.parse(data) as Payload
74 | beatQueueRef.current.push(getBeat(newPayload))
75 | })
76 |
77 | if (isReady) {
78 | return null
79 | }
80 |
81 | return (
82 |
101 | Click anywhere to start the music. Don’t forget to turn off silent mode!
102 |
103 | )
104 | }
105 |
106 | export default MusicGenerator
107 |
--------------------------------------------------------------------------------
/app/src/components/ToggleModeButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button, SxStyleProp, useColorMode } from 'theme-ui'
3 |
4 | export interface ToggleModeButtonProps {
5 | sx?: SxStyleProp
6 | }
7 |
8 | const ToggleModeButton = ({ sx, ...props }: ToggleModeButtonProps) => {
9 | const [colorMode, setColorMode] = useColorMode()
10 |
11 | return (
12 |
35 | )
36 | }
37 |
38 | export default ToggleModeButton
39 |
--------------------------------------------------------------------------------
/app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './components/App'
4 | import * as serviceWorker from './serviceWorker'
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | )
12 |
13 | serviceWorker.unregister()
14 |
--------------------------------------------------------------------------------
/app/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/app/src/react-reveal.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-reveal'
2 |
--------------------------------------------------------------------------------
/app/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | )
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void
26 | }
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
32 | if (publicUrl.origin !== window.location.origin) {
33 | // Our service worker won't work if PUBLIC_URL is on a different origin
34 | // from what our page is served on. This might happen if a CDN is used to
35 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
36 | return
37 | }
38 |
39 | window.addEventListener('load', () => {
40 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
41 |
42 | if (isLocalhost) {
43 | // This is running on localhost. Let's check if a service worker still exists or not.
44 | checkValidServiceWorker(swUrl, config)
45 |
46 | // Add some additional logging to localhost, pointing developers to the
47 | // service worker/PWA documentation.
48 | navigator.serviceWorker.ready.then(() => {
49 | console.log(
50 | 'This web app is being served cache-first by a service ' +
51 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
52 | )
53 | })
54 | } else {
55 | // Is not localhost. Just register service worker
56 | registerValidSW(swUrl, config)
57 | }
58 | })
59 | }
60 | }
61 |
62 | function registerValidSW(swUrl: string, config?: Config) {
63 | navigator.serviceWorker
64 | .register(swUrl)
65 | .then(registration => {
66 | registration.onupdatefound = () => {
67 | const installingWorker = registration.installing
68 | if (installingWorker == null) {
69 | return
70 | }
71 | installingWorker.onstatechange = () => {
72 | if (installingWorker.state === 'installed') {
73 | if (navigator.serviceWorker.controller) {
74 | // At this point, the updated precached content has been fetched,
75 | // but the previous service worker will still serve the older
76 | // content until all client tabs are closed.
77 | console.log(
78 | 'New content is available and will be used when all ' +
79 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
80 | )
81 |
82 | // Execute callback
83 | if (config && config.onUpdate) {
84 | config.onUpdate(registration)
85 | }
86 | } else {
87 | // At this point, everything has been precached.
88 | // It's the perfect time to display a
89 | // "Content is cached for offline use." message.
90 | console.log('Content is cached for offline use.')
91 |
92 | // Execute callback
93 | if (config && config.onSuccess) {
94 | config.onSuccess(registration)
95 | }
96 | }
97 | }
98 | }
99 | }
100 | })
101 | .catch(error => {
102 | console.error('Error during service worker registration:', error)
103 | })
104 | }
105 |
106 | function checkValidServiceWorker(swUrl: string, config?: Config) {
107 | // Check if the service worker can be found. If it can't reload the page.
108 | fetch(swUrl, {
109 | headers: { 'Service-Worker': 'script' }
110 | })
111 | .then(response => {
112 | // Ensure service worker exists, and that we really are getting a JS file.
113 | const contentType = response.headers.get('content-type')
114 | if (
115 | response.status === 404 ||
116 | (contentType != null && contentType.indexOf('javascript') === -1)
117 | ) {
118 | // No service worker found. Probably a different app. Reload the page.
119 | navigator.serviceWorker.ready.then(registration => {
120 | registration.unregister().then(() => {
121 | window.location.reload()
122 | })
123 | })
124 | } else {
125 | // Service worker found. Proceed as normal.
126 | registerValidSW(swUrl, config)
127 | }
128 | })
129 | .catch(() => {
130 | console.log(
131 | 'No internet connection found. App is running in offline mode.'
132 | )
133 | })
134 | }
135 |
136 | export function unregister() {
137 | if ('serviceWorker' in navigator) {
138 | navigator.serviceWorker.ready
139 | .then(registration => {
140 | registration.unregister()
141 | })
142 | .catch(error => {
143 | console.error(error.message)
144 | })
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/app/src/theme.ts:
--------------------------------------------------------------------------------
1 | const theme = {
2 | colors: {
3 | primary: '#ee9ca7',
4 | background: '#ffffff',
5 | text: '#14171a',
6 | white: '#ffffff',
7 | blue: '#1b95e0',
8 | green: '#17bf63',
9 | red: '#e0245e',
10 | orange: '#f45d22',
11 | purple: '#794bc4',
12 | modes: {
13 | dark: {
14 | background: '#000000',
15 | text: '#ffffff'
16 | }
17 | }
18 | },
19 | fonts: {
20 | body: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif`,
21 | heading: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif`
22 | },
23 | styles: {
24 | root: {
25 | fontFamily: 'body',
26 | fontSize: '15px',
27 | lineHeight: 1.3125
28 | },
29 | a: {
30 | color: 'primary',
31 | textDecorationLine: 'none',
32 | ':hover': {
33 | textDecorationLine: 'underline'
34 | }
35 | }
36 | },
37 | useColorSchemeMediaQuery: true
38 | }
39 |
40 | export default theme
41 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "allowSyntheticDefaultImports": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "isolatedModules": true,
8 | "jsx": "react",
9 | "lib": ["dom", "dom.iterable", "esnext"],
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "resolveJsonModule": true,
14 | "skipLibCheck": true,
15 | "strict": true,
16 | "target": "es5"
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": [
4 | "app",
5 | "server"
6 | ],
7 | "scripts": {
8 | "build": "yarn build:server && yarn build:app",
9 | "build:app": "yarn workspace @iora/app build",
10 | "build:server": "yarn workspace @iora/server build",
11 | "dev": "concurrently --kill-others-on-fail 'yarn:dev:*'",
12 | "dev:app": "yarn workspace @iora/app dev",
13 | "dev:server": "yarn workspace @iora/server dev",
14 | "start": "yarn workspace @iora/server start"
15 | },
16 | "devDependencies": {
17 | "concurrently": "^5.3.0"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@iora/server",
3 | "version": "1.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "main": "./build/index.js",
7 | "scripts": {
8 | "build": "yarn clean && tsc",
9 | "clean": "rm -rf ./build && mkdir ./build",
10 | "dev": "nodemon -r dotenv/config --watch './src/**/*.ts' --exec ts-node ./src/index.ts",
11 | "start": "NODE_ENV=production node ./build/index.js"
12 | },
13 | "dependencies": {
14 | "axios": "^0.19.2",
15 | "cors": "^2.8.5",
16 | "express": "^4.17.1",
17 | "socket.io": "^2.3.0"
18 | },
19 | "devDependencies": {
20 | "@types/axios": "^0.14.0",
21 | "@types/cors": "^2.8.7",
22 | "@types/express": "^4.17.7",
23 | "@types/node": "^14.0.27",
24 | "@types/socket.io": "^2.1.11",
25 | "dotenv": "^8.2.0",
26 | "nodemon": "^2.0.4",
27 | "ts-node": "^8.10.2",
28 | "typescript": "^3.9.7"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/server/src/config.ts:
--------------------------------------------------------------------------------
1 | interface Config {
2 | port: number
3 | isProduction: boolean
4 | bearerToken?: string
5 | }
6 |
7 | const config: Config = {
8 | port: parseInt(process.env.PORT || '') || 3000,
9 | isProduction: process.env.NODE_ENV === 'production',
10 | bearerToken: process.env.TWITTER_BEARER_TOKEN
11 | }
12 |
13 | export default config
14 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosRequestConfig } from 'axios'
2 | import cors from 'cors'
3 | import express from 'express'
4 | import http from 'http'
5 | import path from 'path'
6 | import socketIo from 'socket.io'
7 | import config from './config'
8 |
9 | // Start Express + Socket.IO server
10 | console.log('Starting Iora…')
11 | const app = express()
12 | const server = http.createServer(app)
13 | const io = socketIo(server)
14 |
15 | app.use(cors())
16 | if (config.isProduction) {
17 | app.use(express.static(path.join(__dirname, '../../app/build')))
18 | }
19 |
20 | // Start receiving requests
21 | server.listen(config.port, async () => {
22 | await getTweetSamples()
23 | })
24 | console.log(`Listening on port ${config.port}`)
25 |
26 | const EXPANSIONS = [
27 | 'attachments.media_keys',
28 | 'attachments.poll_ids',
29 | 'author_id',
30 | 'in_reply_to_user_id',
31 | 'referenced_tweets.id',
32 | 'referenced_tweets.id.author_id'
33 | ]
34 | const TWEET_FIELDS = [
35 | 'attachments',
36 | 'created_at',
37 | 'geo',
38 | 'in_reply_to_user_id',
39 | 'lang',
40 | 'possibly_sensitive',
41 | 'public_metrics',
42 | 'referenced_tweets'
43 | ]
44 | const MEDIA_FIELDS = ['media_key', 'type']
45 | const POLL_FIELDS = ['id']
46 | const USER_FIELDS = [
47 | 'name',
48 | 'profile_image_url',
49 | 'public_metrics',
50 | 'username',
51 | 'verified'
52 | ]
53 |
54 | const axiosOptions: AxiosRequestConfig = {
55 | method: 'GET',
56 | baseURL: 'https://api.twitter.com/',
57 | url: '/2/tweets/sample/stream',
58 | headers: {
59 | Authorization: `Bearer ${config.bearerToken}`
60 | },
61 | params: {
62 | expansions: EXPANSIONS.join(','),
63 | 'media.fields': MEDIA_FIELDS.join(','),
64 | 'poll.fields': POLL_FIELDS.join(','),
65 | 'tweet.fields': TWEET_FIELDS.join(','),
66 | 'user.fields': USER_FIELDS.join(',')
67 | },
68 | responseType: 'stream',
69 | timeout: 5 * 1000
70 | }
71 |
72 | const getTweetSamples = async () => {
73 | try {
74 | const { data } = await axios.request(axiosOptions)
75 |
76 | console.log('Connected to Twitter stream')
77 | data.on('data', emitTweet)
78 |
79 | data.on('close', () => {
80 | console.log(
81 | 'The connection to Twitter’s stream endpoint was closed. Retrying in 10 seconds…'
82 | )
83 | io.emit('stream_close')
84 | setTimeout(async () => {
85 | await getTweetSamples()
86 | }, 10 * 1000)
87 | })
88 | } catch (e) {
89 | // TODO: Implement proper linear and exponential backoff strategies according
90 | // to https://developer.twitter.com/en/docs/twitter-api/tweets/sampled-stream/integrate/handling-disconnections.
91 | console.log(
92 | 'An error occurred while trying to connect to Twitter’s stream endpoint. Retrying in 10 seconds…'
93 | )
94 | setTimeout(async () => {
95 | await getTweetSamples()
96 | }, 10 * 1000)
97 | }
98 | }
99 |
100 | const TWEETS_TO_WAIT = 20
101 | let tweetsSinceLastEmit = 0
102 | const emitTweet = (data: Buffer) => {
103 | const dataStr = data.toString()
104 |
105 | // Don't forward heartbeats
106 | if (dataStr === '\r\n') {
107 | return
108 | }
109 |
110 | tweetsSinceLastEmit++
111 | if (tweetsSinceLastEmit !== TWEETS_TO_WAIT) {
112 | return
113 | }
114 |
115 | io.volatile.emit('tweet', dataStr)
116 | tweetsSinceLastEmit = 0
117 | }
118 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "esModuleInterop": true,
5 | "forceConsistentCasingInFileNames": true,
6 | "module": "commonjs",
7 | "noFallthroughCasesInSwitch": true,
8 | "noImplicitReturns": true,
9 | "noUnusedLocals": true,
10 | "noUnusedParameters": true,
11 | "outDir": "build",
12 | "resolveJsonModule": true,
13 | "rootDir": "src",
14 | "skipLibCheck": true,
15 | "strict": true,
16 | "target": "es2018"
17 | },
18 | "compileOnSave": true,
19 | "include": ["src"]
20 | }
21 |
--------------------------------------------------------------------------------