├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── README.md ├── actioncable.js ├── config.env.template ├── eslint.config.js ├── fly.toml ├── html.js ├── html ├── about.snippet.html ├── escape-html-htmx-extension.snippet.html └── mixpanel.snippet.html ├── index.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── personalizations.css ├── personalizations │ ├── add-room-name-copy-to-clipboard-button.js │ ├── confetti-once.html │ ├── hannahs-colorful-rooms.css │ ├── hide-fouc.css │ ├── icons.css │ ├── rainbow-gradient-animated.css │ ├── rainbowify-participant-borders.js │ ├── rcverse-base-style.css │ ├── recurse-com__cherry-picked.css │ ├── recurse-com__font-awesome.css │ ├── recurse-com__header.html │ ├── register-service-worker.js │ ├── retro-futurism.html │ └── show-fouc.css ├── recurse-community-bot.png └── service-worker-driver.js └── scripts └── generate-cert.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | *.tern-port 7 | node_modules/ 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | *.tsbuildinfo 12 | .npm 13 | .eslintcache 14 | *.env 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.md] 17 | trim_trailing_whitespace = true 18 | 19 | [*.json] 20 | indent_size = 2 21 | 22 | [*.{html,js,md}] 23 | block_comment_start = /** 24 | block_comment = * 25 | block_comment_end = */ 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | *.tern-port 7 | node_modules/ 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | *.tsbuildinfo 12 | .npm 13 | .eslintcache 14 | *.env 15 | cert/ 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=20.8.1 5 | FROM node:${NODE_VERSION}-slim AS base 6 | 7 | LABEL fly_launch_runtime="Node.js" 8 | 9 | # Node.js app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV="production" 14 | 15 | 16 | # Throw-away build stage to reduce size of final image 17 | FROM base AS build 18 | 19 | # Install packages needed to build node modules 20 | RUN apt-get update -qq && \ 21 | apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 22 | 23 | # Install node modules 24 | COPY --link package-lock.json package.json ./ 25 | RUN npm ci 26 | 27 | # Copy application code 28 | COPY --link . . 29 | 30 | 31 | # Final stage for app image 32 | FROM base 33 | 34 | # Copy built application 35 | COPY --from=build /app /app 36 | 37 | # Start the server by default, this can be overwritten at runtime 38 | EXPOSE 3001 39 | CMD [ "node", "index.js" ] 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RCVerse 2 | 3 | Forked from [Recurse OAuth example](https://github.com/reedspool/recurse-oauth-example-node-express) 4 | 5 | [RC Wiki page](https://github.com/recursecenter/wiki/wiki/RCVerse) 6 | 7 | [Uses the RC API](https://github.com/recursecenter/wiki/wiki/Recurse-Center-API) 8 | 9 | [Doesn't do calendar stuff like RSVP Bot](https://github.com/recursecenter/RSVPBot?tab=readme-ov-file#developing-without-api-access) 10 | 11 | ## Setup Your Local Development Environment 12 | 13 | Node >=20 required (or see notes about Oslo installation for Node <20 [here](https://oslo.js.org)) 14 | 15 | ```sh 16 | npm install; 17 | ``` 18 | 19 | To get your Client ID and Client Secret, go to , and click 'Create OAuth Application'. Use `http://localhost:3001/myOauth2RedirectUri` as the Redirect URI. 20 | 21 | Then make a copy of `config.env.template` named `config.env` and fill in the secrets there. For PostgreSQL, see the Neon section below. 22 | 23 | Once you've done the above steps, you can start your local dev environment. 24 | 25 | ## Run Local Development Environment 26 | 27 | Once you've got all your local configuration done, including setting up and running a database, you should be able to run the development server with: 28 | 29 | ```sh 30 | npm start 31 | ``` 32 | 33 | ## Fly.io Deployment 34 | 35 | `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET` and `POSTGRES_CONNECTION` are set as Secrets inside the Fly App. 36 | 37 | When you update those values, you'll need to run `fly deploy` to use the new versions. 38 | 39 | ## Neon deployment/PostgreSQL dev environment system 40 | 41 | RCVerse production uses [Neon](https://neon.tech/) to host a PostgreSQL database. You don't need to use Neon, any PostgreSQL server should work. 42 | 43 | If you want to use Neon, you'll need to sign up for a new account. Make a new database. Save the PostgreSQL connection string into the `config.env` variable `POSTGRES_CONNECTION` as well as in the Secrets section of your Fly App. 44 | 45 | Create some tables. Run these queries separately in the Neon "SQL Editor": 46 | 47 | ```sql 48 | CREATE TABLE auth_user ( 49 | id TEXT PRIMARY KEY 50 | ) 51 | ``` 52 | 53 | and this one. Note it's different from the Lucia tutorial [here](https://lucia-auth.com/database/postgresql) in that it includes a `refresh_token` field: 54 | 55 | ```sql 56 | CREATE TABLE user_session ( 57 | id TEXT PRIMARY KEY, 58 | expires_at TIMESTAMPTZ NOT NULL, 59 | user_id TEXT NOT NULL REFERENCES auth_user(id), 60 | refresh_token TEXT 61 | ) 62 | ``` 63 | 64 | 65 | Then create a table and fill it with all the standard RC room information. This table should be write-only most of the time, since RC rooms rarely change. You can locate the values of the `note_block_rctogether_id` field by watching the websocket messages in RCTogether when you save an edit to a note block. 66 | 67 | ```sql 68 | CREATE TABLE zoom_rooms ( 69 | id serial PRIMARY KEY, 70 | room_name TEXT, 71 | location TEXT, /* Usually URL */ 72 | note_block_rctogether_id TEXT, /* Associated note block ID in VirtualRC */ 73 | visibility TEXT /* if not "visible", shouldn't appear in UI */ 74 | ); 75 | 76 | INSERT INTO zoom_rooms (room_name, location, note_block_rctogether_id, visibility) 77 | VALUES 78 | ('Aegis','https://www.recurse.com/zoom/aegis', NULL, 'visible'), 79 | ('Arca','https://www.recurse.com/zoom/arca', NULL, 'visible'), 80 | ('Edos','https://www.recurse.com/zoom/edos', NULL, 'visible'), 81 | ('Genera','https://www.recurse.com/zoom/genera', NULL, 'visible'), 82 | ('Midori','https://www.recurse.com/zoom/midori', NULL, 'visible'), 83 | ('Verve','https://www.recurse.com/zoom/verve', NULL, 'visible'), 84 | ('Couches','https://www.recurse.com/zoom/couches', '81750', 'visible'), 85 | ('Kitchen','https://www.recurse.com/zoom/kitchen', NULL, 'visible'), 86 | ('Pairing Station 1','https://www.recurse.com/zoom/pairing_station_1', '152190', 'visible'), 87 | ('Pairing Station 2','https://www.recurse.com/zoom/pairing_station_2', '152189', 'visible'), 88 | ('Pairing Station 3','https://www.recurse.com/zoom/pairing_station_3', '152193', 'visible'), 89 | ('Pairing Station 4','https://www.recurse.com/zoom/pairing_station_4', '152191', 'visible'), 90 | ('Pairing Station 5','https://www.recurse.com/zoom/pairing_station_5', '140538', 'visible'), 91 | ('Pairing Station 6','https://recurse.rctogether.com/zoom_meetings/35980/join', '152198', 'visible'), 92 | ('Pairing Station 7','https://recurse.rctogether.com/zoom_meetings/35983/join', '152192', 'visible'), 93 | ('Pomodoro Room','https://www.recurse.com/zoom/pomodoro_room', NULL, 'visible'), 94 | ('Presentation Space','https://www.recurse.com/zoom/presentation_space', NULL, 'visible'), 95 | ('Faculty Area','https://www.recurse.com/zoom/faculty_area', NULL, 'visible'), 96 | ('Faculty Lounge','https://www.recurse.com/zoom/faculty_lounge', NULL, 'visible'); 97 | ``` 98 | 99 | Above are all the visible rooms, but some rooms are "invisible" to RCVerse, like personal rooms of RC faculty. To add an invisible room, insert a new invisible row. The difference between simply leaving a room out and adding the invisible row to the database is that if there is no associated "invisible" row in the database, a log message will appear warning there's a "surprising" zoom room that's not tracked. You can ignore this though. 100 | 101 | To edit the rooms on production RCVerse, please ask Reed. But here's an example addition of an invisible room: 102 | 103 | ```sql 104 | INSERT INTO zoom_rooms (room_name, location, visibility) 105 | VALUES 106 | ('Reed\'s Roomba Collection','https://www.recurse.com/zoom/reeds-roombas,'invisible'); 107 | ``` 108 | 109 | ## RCTogether API (ActionCable) 110 | 111 | [The documentation for RCTogether's APIs lives here.](https://docs.rctogether.com/#introduction) 112 | 113 | Go to https://recurse.rctogether.com/apps and make a new application, then plug in your App ID and App secret into `ACTION_CABLE_APP_ID` and `ACTION_CABLE_APP_SECRET` in your `config.env`. 114 | 115 | ## Recurse.com Calendar 116 | 117 | The calendar integration downloads the iCalendar export (`.ics`) from the Recurse.com calendar application. Go to [recurse.com/settings/calendar](https://www.recurse.com/settings/calendar). In the `Subscription URL` field, you'll find a URL. That URL will have a query parameter called `token`. Paste the value of that token (everything _after_ `token=`, not including those characters) into the `RECURSE_CALENDAR_TOKEN` value in your `config.env`. 118 | 119 | ## Super Secret Auth Bypass Token 120 | 121 | In `config.env.template` you will see `SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE`. This is used to bypass authentication for the special case where a kiosk like RCTV needs a hard-coded authentication instead of RC OAuth. This should only be used for local testing or in the case of a kiosk, it should never be stored in plain text or be visible to anyone in plain text. RCTV will hide this specific URL to hide this token. `crypto.randomUUID()` is a good way to make a new UUID for this purpose. Maybe prepend a signifier like `rctv-` to the UUID produced, so if it gets leaked you have some inkling where its from. 122 | 123 | ## Mixpanel 124 | 125 | For web stats tracking, we're using Mixpanel. You'll need to get an application token for it and set the `MIXPANEL_TOKEN` env variable. I suggest not setting this token correctly in your local `config.env` file, and only setting it properly in the deployment, so you don't confuse local development tracking data with production data. 126 | -------------------------------------------------------------------------------- /actioncable.js: -------------------------------------------------------------------------------- 1 | import ActionCable from "actioncable-nodejs/src/actioncable.js"; 2 | 3 | // TODO: I'm going to crash the server if I ever get a second world message 4 | // at this early stage of development because I really want tok now if that 5 | // ever happens. This can be a console log later 6 | let hasSeenWorldDataWithoutReconnect = false; 7 | export function connect(APP_ID, APP_SECRET, emitter) { 8 | const uri = `wss://recurse.rctogether.com/cable?app_id=${APP_ID}&app_secret=${APP_SECRET}`; 9 | 10 | let cable = new ActionCable(uri, { 11 | origin: "https://example.rctogether.com", 12 | }); 13 | 14 | function reconnect() { 15 | hasSeenWorldDataWithoutReconnect = false; 16 | connect(APP_ID, APP_SECRET, emitter); 17 | } 18 | 19 | return cable.subscribe("ApiChannel", { 20 | connected() { 21 | console.log("Connected to ActionCable RC Together Streaming API"); 22 | }, 23 | 24 | disconnected() { 25 | // TODO Implement reconnection and simply let the user know the data's out of date 26 | console.error("ActionCable RC Together API stream disconnected"); 27 | console.error("Scheduling reconnect in 10 seconds"); 28 | setTimeout(reconnect, 10 * 1000); 29 | }, 30 | 31 | rejected() { 32 | console.error("ActionCable RC Together API stream disconnected"); 33 | console.error("Scheduling reconnect in 10 seconds"); 34 | setTimeout(reconnect, 10 * 1000); 35 | }, 36 | 37 | received({ type, payload }) { 38 | try { 39 | if (type === "world") { 40 | emitter.emit("participant-room-data-reset"); 41 | // Parse the initial dump of world data 42 | if (hasSeenWorldDataWithoutReconnect) 43 | // This is just a bit confusing but not that problematic 44 | console.error("Saw world data twice without a reconnect"); 45 | hasSeenWorldDataWithoutReconnect = true; 46 | payload.entities.forEach((entity) => { 47 | const { type, name, zoom_user_display_name } = entity; 48 | if (type === "Bot" && name?.match(/rcverse/i)) { 49 | console.error(`Uncleaned bot found: ${entity.id}`, entity); 50 | } else if (type === "Note") { 51 | const { id, note_text, note_updated_at } = entity; 52 | emitter.emit("room-note-data", { 53 | id: String(id), 54 | content: note_text, 55 | updatedTimestamp: note_updated_at, 56 | }); 57 | } else if (type === "Avatar" && zoom_user_display_name !== null) { 58 | const { 59 | person_name, 60 | image_path, 61 | last_seen_at, 62 | rc_hub_visit_today, 63 | flair, 64 | } = entity; 65 | 66 | const lastSeenMillis = new Date(last_seen_at).getTime(); 67 | const millisSinceLastSeen = Date.now() - lastSeenMillis; 68 | const hourInMillis = 1000 * 60 * 60; 69 | // If we haven't been seen in one hour and 15 minutes 70 | // TODO: Contact James Porter to attempt to fix the bug where people 71 | // remain in the zoom room forever 72 | // NOTE: For groups like Music Consumption Group, that hang in Zoom 73 | // for many many hours, we DO want the long "since last seen" 74 | // but for the bug where people stay in the channel forever, we don't 75 | // Tricky tricky. 76 | if (millisSinceLastSeen > 5 * hourInMillis) return; 77 | 78 | emitter.emit("participant-room-data", { 79 | participantName: person_name, 80 | roomName: zoom_user_display_name, 81 | faceMarkerImagePath: image_path, 82 | inTheHub: rc_hub_visit_today, 83 | lastBatch: flair, 84 | }); 85 | } else if ( 86 | type === "UnknownAvatar" && 87 | zoom_user_display_name !== null 88 | ) { 89 | const { person_name, image_path } = entity; 90 | 91 | emitter.emit("participant-room-data", { 92 | participantName: person_name, 93 | roomName: zoom_user_display_name, 94 | faceMarkerImagePath: image_path, 95 | inTheHub: false, 96 | lastBatch: "", 97 | }); 98 | } 99 | }); 100 | } else if (type === "entity") { 101 | const { type } = payload; 102 | if (type === "Note") { 103 | const { id, note_text, note_updated_at } = payload; 104 | emitter.emit("room-note-data", { 105 | id: String(id), 106 | content: note_text, 107 | updatedTimestamp: note_updated_at, 108 | }); 109 | } else if (type === "Avatar") { 110 | const { 111 | person_name, 112 | zoom_user_display_name, 113 | image_path, 114 | rc_hub_visit_today, 115 | flair, 116 | } = payload; 117 | emitter.emit("participant-room-data", { 118 | participantName: person_name, 119 | roomName: zoom_user_display_name, 120 | faceMarkerImagePath: image_path, 121 | inTheHub: rc_hub_visit_today, 122 | lastBatch: flair, 123 | }); 124 | } else if (type === "UnknownAvatar") { 125 | const { person_name, image_path, zoom_user_display_name } = payload; 126 | 127 | emitter.emit("participant-room-data", { 128 | participantName: person_name, 129 | roomName: zoom_user_display_name, 130 | faceMarkerImagePath: image_path, 131 | inTheHub: false, 132 | lastBatch: "", 133 | }); 134 | } 135 | } 136 | } catch (error) { 137 | console.error("ActionCable couldn't handle an error:", error); 138 | } 139 | }, 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /config.env.template: -------------------------------------------------------------------------------- 1 | OAUTH_CLIENT_ID= 2 | OAUTH_CLIENT_SECRET= 3 | POSTGRES_CONNECTION= 4 | ACTION_CABLE_APP_ID= 5 | ACTION_CABLE_APP_SECRET= 6 | SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE= 7 | MIXPANEL_TOKEN= 8 | RECURSE_CALENDAR_TOKEN= 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import stylistic from "@stylistic/eslint-plugin"; 3 | 4 | export default [ 5 | { 6 | languageOptions: { globals: globals.node }, 7 | plugins: { 8 | "@stylistic": stylistic, 9 | }, 10 | rules: { 11 | indent: ["error", 2], 12 | "@stylistic/indent": ["error", 2], 13 | "@stylistic/no-tabs": ["error"], 14 | }, 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for rcverse on 2024-03-08T01:02:02-05:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'rcverse' 7 | primary_region = 'ewr' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 3001 13 | force_https = true 14 | auto_stop_machines = false 15 | auto_start_machines = true 16 | min_machines_running = 1 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '1gb' 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | 24 | # We reached error when everyone joined during a presentation: 25 | # 26 | # Instance 6e82403da07628 reached hard limit of 25 concurrent connections. 27 | # This usually indicates your app is not closing connections properly or is 28 | # not closing them fast enough for the traffic levels it is handling. Scaling 29 | # resources, number of instances or increasing your hard limit might help. 30 | # 31 | # https://fly.io/docs/reference/load-balancing/#web-service 32 | # 33 | # Also note: It appears that since I have a [http_service] section, [services] is 34 | # ignored? Originally tried these exact settings in 35 | # [connections.concurrency] 36 | [http_service.concurrency] 37 | type = "requests" 38 | hard_limit = 200 39 | soft_limit = 100 40 | -------------------------------------------------------------------------------- /html.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HTML component system with raw string templates. 3 | */ 4 | import { readFileSync } from "node:fs"; 5 | 6 | // A tagged template function to invoke Prettier's built-in formatting 7 | // See https://prettier.io/blog/2020/08/24/2.1.0.html 8 | const html = (...args) => String.raw(...args); 9 | 10 | const snippets = {}; 11 | 12 | const useSnippet = (path) => { 13 | let content = snippets[path]; 14 | if (!content) { 15 | content = readFileSync(path); 16 | snippets[path] = content; 17 | } 18 | return content; 19 | }; 20 | 21 | // Generic complete HTML page 22 | export const Page = ({ body, title, mixpanelToken, myRcUserId }) => html` 23 | 24 | 25 | 26 | ${title} 27 | 28 | 29 | 30 | ${ 31 | "" 32 | /* Mixpanel insert from https://docs.mixpanel.com/docs/quickstart/connect-your-data?sdk=javascript */ 33 | /* NOTE: Had to double escape forward slashes, i.e. replace "\/" with "\\/" */ 34 | } 35 | ${useSnippet("./html/mixpanel.snippet.html")} 36 | 53 | 54 | 55 | 60 | ${useSnippet("./html/escape-html-htmx-extension.snippet.html")} 61 | 62 | 63 | ${body} 64 | 65 | 66 | `; 67 | 68 | export const RootBody = ({ roomListContent, personalizations, sort }) => { 69 | let body = ""; 70 | // Propogate settings back to the websocket connection which are not stored in 71 | // a cookie or are otherwise stateless 72 | body += `
`; 73 | body += html`

RCVerse

`; 74 | body += useSnippet("./html/about.snippet.html"); 75 | body += `
`; 76 | body += html`Personalizations`; 77 | 78 | body += html`

79 | Edit your personalizations here 80 |

`; 81 | body += html`

82 | Each personalization is applied and then repeated as escaped HTML in text 83 | form so you can see exactly what's going on. 84 |

`; 85 | if (!personalizations || personalizations.length === 0) { 86 | body += html`

You have no personalizations

`; 87 | } 88 | body += `
    `; 89 | body += personalizations 90 | .map(({ url }) => { 91 | const include = url.endsWith(".css") 92 | ? CSSInclude({ url }) 93 | : url.endsWith(".html") 94 | ? HTMLInclude({ url }) 95 | : url.endsWith(".js") 96 | ? JSInclude({ url }) 97 | : ""; 98 | 99 | return html`
  • 100 |
    ${escapeHtml(include)}
    101 |
    ${include}
    102 | 103 |
    104 | Code 105 |
    112 |
    113 |
  • `; 114 | }) 115 | .join("\n"); 116 | body += `
`; 117 | // Send current list of personalizations to service worker to cache 118 | body += html``; 144 | body += `
`; 145 | body += html` 146 | ${roomListContent} 147 | 148 |

You're logged in! - log out

149 | `; 150 | 151 | body += html`
`; 152 | 153 | return body; 154 | }; 155 | 156 | export const RoomList = ({ whoIsInTheHub, rooms }) => html` 157 |
158 | ${WhoIsInTheHub(whoIsInTheHub)} ${rooms.map(Room).join("\n")} 159 |
160 | `; 161 | 162 | export const JSInclude = ({ url }) => html``; 163 | export const CSSInclude = ({ url }) => 164 | html``; 165 | export const HTMLInclude = ({ url }) => html` 166 |
172 | `; 173 | 174 | // TODO: Could have an iframe preview of the homepage which htmx triggers to 175 | // refresh on every change, but for now can test by refreshing another tab 176 | // TODO: Make a wiki page and link it here as a repository of nice 177 | // personalizations. Probably everything can be on the wiki and can just link to 178 | // the personalization page and the wiki page on the home page and get rid of the 179 | // other words therein 180 | export const Personalization = ({ 181 | personalizations, 182 | defaultPersonalizations, 183 | }) => { 184 | return html`
185 | 186 |

RCVerse Personalizations

187 | 188 |

189 | Your personalizations are listed below. Personalizations aren't applied on 190 | this page so hopefully this page doesn't break. To test your changes, I 191 | recommend opening the 192 | homepage in another tab and refreshing it 193 | after each change. 194 |

195 | 196 | Back to RCVerse Home 197 | 198 | ${!personalizations || personalizations.length == 0 199 | ? html`

You have no personalizations

` 200 | : html`
    201 | ${personalizations 202 | .map(({ url, cache }, index) => 203 | PersonalizationListItem({ 204 | url, 205 | cache, 206 | index, 207 | total: personalizations.length, 208 | }), 209 | ) 210 | .join("\n")} 211 |
`} 212 | 213 |

Add personalization

214 | 215 |

216 | You can find 217 | URLs of community-made personalizations in the wiki. 222 |

223 | 224 |

225 | There's a lot more information about how the Personalization system works 226 | and how to make your own on 227 | the wiki page. 232 |

233 | 234 |
235 | 239 | 240 | 241 |

242 | Security note: Make sure you trust this URL. Check out the contents 243 | yourself and ensure you believe it won't change. 244 |

245 |
246 | 247 |

Reset

248 | 249 |

250 | This will reset your personalizations to the current defaults. The current 251 | defaults are listed below so you could manually add what you're missing 252 | yourself instead of wiping everything out. 253 |

254 | 255 |
    256 | ${defaultPersonalizations 257 | .map(({ url }) => html`
  1. ${url}
  2. `) 258 | .join("\n")} 259 |
260 | 261 |

262 | To confirm and reset your personalizations, please type "confirm" in the 263 | text box. Then hit the button. 264 |

265 |
266 | 267 | 268 | 269 |
270 |
`; 271 | }; 272 | export const PersonalizationListItem = ({ url, cache, index, total }) => { 273 | const isFirst = index === 0; 274 | const isLast = index === total - 1; 275 | return html`
  • 276 | ${url} - Visit 277 |
    278 | 279 | 280 | 281 | ${ 282 | isFirst 283 | ? "" 284 | : html`` 285 | } 286 | ${ 287 | isLast 288 | ? "" 289 | : html`` 290 | } 291 |
  • `; 294 | }; 295 | export const Login = ({ reason } = { reason: "" }) => html` 296 |
    297 |

    RCVerse

    298 |

    Whatever you make it

    299 | ${reason === "deauthenticated" 300 | ? html`

    301 | RC Auth said you're not logged in. This might be temporary, so you 302 | might try to refresh and be logged in. Please let Reed know if this 303 | happens frequently. 304 |

    ` 305 | : ""} 306 |

    Log in

    307 |
    308 | `; 309 | 310 | export const Room = ({ 311 | roomLocation, 312 | roomName, 313 | isEmpty, 314 | participants, 315 | hasNote = false, 316 | noteContent, 317 | noteDateTime, 318 | noteHowManyMinutesAgo, 319 | countPhrase, 320 | hasNowEvent, 321 | nowEventName, 322 | nowEventStartedHowManyMinutesAgo, 323 | nowEventCalendarUrl, 324 | nowEventDateTime, 325 | hasNextEvent, 326 | nextEventName, 327 | nextEventStartsInHowLong, 328 | nextEventCalendarUrl, 329 | nextEventDateTime, 330 | }) => html` 331 |
    335 |
    336 |
    337 | 338 |

    ${roomName}

    339 |
    340 |
    341 |
    342 | ${Participants({ participants, countPhrase })} 343 | 344 |

    345 | Join 346 | 352 | ${roomName} 353 | 354 |

    355 | 356 | ${hasNowEvent 357 | ? html` 358 |

    359 | ${nowEventName} 365 | started 366 | 372 |

    373 | ` 374 | : ""} 375 | ${hasNextEvent 376 | ? html` 377 |

    378 | ${nextEventName} 384 | starts 385 | 391 |

    392 | ` 393 | : ""} 394 | ${Note({ 395 | roomName, 396 | hasNote, 397 | noteContent, 398 | noteDateTime, 399 | noteHowManyMinutesAgo, 400 | })} 401 |
    402 |
    403 |
    404 | `; 405 | 406 | export const Note = ({ 407 | roomName, 408 | hasNote, 409 | noteContent, 410 | noteDateTime, 411 | noteHowManyMinutesAgo, 412 | }) => html` 413 |
    414 |
    ${noteContent}
    415 | 416 | 424 | ${hasNote 425 | ? html` 426 | Updated 427 | 428 | ` 429 | : ""} 430 | 431 |
    432 | `; 433 | 434 | export const EditNoteForm = ({ 435 | roomName, 436 | noteContent, 437 | hasVirtualRCConnectedBlock, 438 | }) => html` 439 |
    446 | 447 |
    448 | Note 449 | 458 |

    459 | Use 460 | Markdown 465 | for links, bold, and italics. 466 | ${hasVirtualRCConnectedBlock 467 | ? "Changes synced to VirtualRC." 468 | : "This note is only on RCVerse."} 469 |

    470 |
    471 | 472 |
    473 |
    474 |
    475 | `; 476 | 477 | export const Participants = ({ participants, countPhrase }) => 478 | html`
    479 | ${participants.map((p) => Participant(p)).join("")} 482 | ${countPhrase ? html`${countPhrase}` : ""} 483 |
    `; 484 | 485 | export const Participant = ({ 486 | participantName, 487 | faceMarkerImagePath, 488 | lastBatch, 489 | }) => html` 490 | 496 | `; 497 | 498 | export const WhoIsInTheHub = ({ 499 | isEmpty, 500 | participants, 501 | iAmCheckedIn, 502 | countPhrase, 503 | }) => html` 504 |
    505 |
    506 |
    507 |

    Who is in the hub?

    508 |
    509 |
    510 | ${Participants({ participants, countPhrase })} 511 |

    512 | ${iAmCheckedIn 513 | ? "You are checked in" 514 | : html``} 521 |

    522 |
    523 |
    524 |
    525 | `; 526 | 527 | // Stolen from NakedJSX https://github.com/NakedJSX/core 528 | // Appears to be adapted from this SO answer https://stackoverflow.com/a/77873486 529 | export const escapeHtml = (text) => { 530 | const htmlEscapeMap = { 531 | "&": "&", 532 | "<": "<", 533 | ">": ">", 534 | '"': """, 535 | "'": "'", 536 | }; 537 | 538 | return text.replace(/[&<>"']/g, (m) => htmlEscapeMap[m] ?? ""); 539 | }; 540 | -------------------------------------------------------------------------------- /html/about.snippet.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | About 4 | 5 |

    6 | Visit the 7 | RCVerse Wiki Page 10 |

    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /html/escape-html-htmx-extension.snippet.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /html/mixpanel.snippet.html: -------------------------------------------------------------------------------- 1 | 71 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to RCVerse's JavaScript server! 3 | * 4 | * Code comments are sparse, but you're welcome to add them as you learn about 5 | * the system and make a PR! 6 | */ 7 | import { Lucia, verifyRequestOrigin, TimeSpan } from "lucia"; 8 | import { OAuth2Client, generateState } from "oslo/oauth2"; 9 | import { OAuth2RequestError } from "oslo/oauth2"; 10 | import { Cookie, parseCookies } from "oslo/cookie"; 11 | import express from "express"; 12 | import pg from "pg"; 13 | import { NodePostgresAdapter } from "@lucia-auth/adapter-postgresql"; 14 | import { marked } from "marked"; 15 | import { connect } from "./actioncable.js"; 16 | import EventEmitter from "node:events"; 17 | import { 18 | Page, 19 | RootBody, 20 | Room, 21 | EditNoteForm, 22 | WhoIsInTheHub, 23 | Login, 24 | Personalization, 25 | escapeHtml, 26 | RoomList, 27 | } from "./html.js"; 28 | import expressWebsockets from "express-ws"; 29 | import ical from "node-ical"; 30 | import fs from "node:fs"; 31 | import { formatDistanceToNow } from "date-fns"; 32 | 33 | // Prepare Markdown renderer to force links to always target="_blank" 34 | const renderer = { 35 | link({ tokens, href, title }) { 36 | const text = this.parser.parseInline(tokens); 37 | const titleAttribute = title 38 | ? ` title="${title.replaceAll(/"/g, """)}"` 39 | : ""; 40 | return `${text}`; 41 | }, 42 | }; 43 | 44 | marked.use({ 45 | // See https://marked.js.org/using_advanced#options 46 | async: false, 47 | // Original markdown.pl, which I think RCTogether conforms to 48 | pedantic: true, 49 | renderer, 50 | }); 51 | 52 | // Catch and snuff all uncaught exceptions and uncaught promise rejections. 53 | // We can manually restart the server if it gets into a bad state, but we want 54 | // to preserve the weirdness for as long as possible. 55 | process.on("uncaughtException", function (err) { 56 | console.error("Top-level uncaught exception: " + err, err); 57 | }); 58 | process.on("unhandledRejection", function (err, promise) { 59 | console.error( 60 | "Top level unhandled rejection (promise: ", 61 | promise, 62 | ", reason: ", 63 | err, 64 | ").", 65 | err, 66 | ); 67 | }); 68 | 69 | // Create an event emitter to handle cross-cutting communications 70 | const emitter = new EventEmitter(); 71 | 72 | // Only be warned if the number of listeners for a specific event goes above 73 | // this number. The warning will come in logs (MaxListenersExceededWarning) 74 | emitter.setMaxListeners(100); 75 | 76 | const app = express(); 77 | const port = process.env.PORT || 3001; 78 | 79 | expressWebsockets(app); 80 | 81 | // NOTE: To test on the original host instead of the `recurse.com` proxy, 82 | // change the production base domain to the domain of the host. 83 | // You'll also need to change the OAuth Client ID and Client Secret to 84 | // credentials which have the original host as the redirect URL. 85 | const baseDomain = 86 | process.env.NODE_ENV === "production" 87 | ? `rcverse.recurse.com` 88 | : `localhost:${port}`; 89 | const baseURL = 90 | process.env.NODE_ENV === "production" 91 | ? `https://${baseDomain}` 92 | : `http://${baseDomain}`; 93 | 94 | // Currently unused self-signed SSL certs. Use `npm run generate-cert` to create 95 | // these files 96 | const sslConfig = 97 | process.env.NODE_ENV === "production" 98 | ? {} 99 | : { 100 | key: fs.readFileSync(`./cert/server.key`), 101 | cert: fs.readFileSync(`./cert/server.cert`), 102 | }; 103 | 104 | const authorizeEndpoint = "https://recurse.com/oauth/authorize"; 105 | // TODO P.B. found this required `www` though authorize doesn't. 106 | const tokenEndpoint = "https://www.recurse.com/oauth/token"; 107 | 108 | // From https://www.recurse.com/settings/apps 109 | const clientId = process.env.OAUTH_CLIENT_ID; 110 | const clientSecret = process.env.OAUTH_CLIENT_SECRET; 111 | 112 | const postgresConnection = process.env.POSTGRES_CONNECTION; 113 | 114 | // From https://recurse.rctogether.com/apps 115 | // TODO: Update this to true meaning, "RCTogether App ID/Secret", not just Action Cable but Rest API too 116 | const actionCableAppId = process.env.ACTION_CABLE_APP_ID; 117 | const actionCableAppSecret = process.env.ACTION_CABLE_APP_SECRET; 118 | 119 | // Special auth token (primarily for RCTV) 120 | const secretAuthToken = process.env.SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE; 121 | 122 | // Mixpanel 123 | const mixpanelToken = process.env.MIXPANEL_TOKEN; 124 | 125 | // Recurse.com Calendar 126 | const recurseCalendarToken = process.env.RECURSE_CALENDAR_TOKEN; 127 | 128 | let inTheHubParticipantNames = []; 129 | let roomNameToParticipantNames = {}; 130 | let participantNameToEntity = {}; 131 | 132 | const sql = new pg.Pool({ 133 | connectionString: postgresConnection, 134 | }); 135 | 136 | // TODO: If connection doesn't work, should either restart the server or 137 | // loop and not proceed until it works. 138 | const zoomRoomsResult = await sql.query( 139 | "select room_name, location, note_block_rctogether_id, visibility from zoom_rooms", 140 | [], 141 | ); 142 | 143 | const zoomRooms = []; 144 | 145 | // Zoom Rooms that are reported but that we purposely don't track 146 | const silentZoomRooms = []; 147 | 148 | zoomRoomsResult.rows.forEach( 149 | ({ room_name, location, visibility, note_block_rctogether_id }) => { 150 | if (visibility == "visible") { 151 | zoomRooms.push({ 152 | roomName: room_name, 153 | location, 154 | noteBlockRCTogetherId: note_block_rctogether_id, 155 | }); 156 | } else { 157 | silentZoomRooms.push(room_name); 158 | } 159 | }, 160 | ); 161 | 162 | const zoomRoomNames = zoomRooms.map(({ roomName }) => roomName); 163 | const zoomRoomsByName = {}; 164 | zoomRooms.forEach(({ roomName, ...rest }) => { 165 | zoomRoomsByName[roomName] = { roomName, ...rest }; 166 | }); 167 | const zoomRoomsByLocation = {}; 168 | zoomRooms.forEach(({ location, ...rest }) => { 169 | zoomRoomsByLocation[location] = { location, ...rest }; 170 | }); 171 | 172 | // TODO 173 | // The action cable API sometimes updates with a zoom room participant count, 174 | // and we observed once that virtual RC showed one person who was definitely in 175 | // a zoom room did not appear in virtual RC (or this app) as in that zoom room. 176 | // Question: Does that participant count also reflect that person NOT in the room? 177 | // I'm guessing it will not show the person in the room, because we also observed 178 | // the little bubble in Virutal RC didn't show that person as in the room 179 | emitter.on("participant-room-data-reset", async () => { 180 | inTheHubParticipantNames = []; 181 | roomNameToParticipantNames = {}; 182 | participantNameToEntity = {}; 183 | }); 184 | emitter.on("participant-room-data", async (entity) => { 185 | let { roomName, participantName, faceMarkerImagePath, inTheHub, lastBatch } = 186 | entity; 187 | 188 | if (roomName !== null && !zoomRoomNames.includes(roomName)) { 189 | if (!silentZoomRooms.includes(roomName)) { 190 | console.error(`Surprising zoom room name '${roomName}'`); 191 | } 192 | return; 193 | } 194 | 195 | if (!participantNameToEntity[participantName]) { 196 | participantNameToEntity[participantName] = {}; 197 | } 198 | 199 | // Always want to have the latest of these 200 | participantNameToEntity[participantName] = { 201 | ...participantNameToEntity[participantName], 202 | faceMarkerImagePath, 203 | lastBatch, 204 | }; 205 | 206 | let hubStatusVerb = ""; 207 | if (inTheHub && !participantNameToEntity[participantName]?.inTheHub) { 208 | hubStatusVerb = "enterred"; 209 | inTheHubParticipantNames.push(participantName); 210 | } else if (!inTheHub && participantNameToEntity[participantName]?.inTheHub) { 211 | hubStatusVerb = "left"; 212 | inTheHubParticipantNames = inTheHubParticipantNames.filter( 213 | (name) => name !== participantName, 214 | ); 215 | } 216 | 217 | if (hubStatusVerb) { 218 | // console.log(`${participantName} ${hubStatusVerb} the hub`); 219 | participantNameToEntity[participantName] = { 220 | ...participantNameToEntity[participantName], 221 | inTheHub, 222 | }; 223 | emitter.emit("in-the-hub-change"); 224 | } 225 | 226 | // zoom_room is a string means we're adding a person to that room 227 | const previousRoomName = participantNameToEntity[participantName]?.roomName; 228 | let verb; 229 | if (roomName) { 230 | if (previousRoomName === roomName) { 231 | // Ignore, we already have this person in the right zoom room 232 | return; 233 | } 234 | 235 | // Remove them from their previous room 236 | if (previousRoomName) { 237 | roomNameToParticipantNames[previousRoomName] = roomNameToParticipantNames[ 238 | previousRoomName 239 | ].filter((name) => name !== participantName); 240 | } 241 | 242 | if (!roomNameToParticipantNames[roomName]) { 243 | roomNameToParticipantNames[roomName] = []; 244 | } 245 | 246 | roomNameToParticipantNames[roomName].push(participantName); 247 | 248 | participantNameToEntity[participantName] = { 249 | ...participantNameToEntity[participantName], 250 | roomName, 251 | }; 252 | 253 | verb = "enterred"; 254 | } else { 255 | if (typeof previousRoomName === "undefined" || !previousRoomName) { 256 | // Ignore, nothing to update, they're still not in a zoom room 257 | return; 258 | } 259 | 260 | // Remove them from their previous room 261 | if (previousRoomName) { 262 | roomNameToParticipantNames[previousRoomName] = roomNameToParticipantNames[ 263 | previousRoomName 264 | ].filter((name) => name !== participantName); 265 | } 266 | 267 | participantNameToEntity[participantName] = { 268 | ...participantNameToEntity[participantName], 269 | roomName, 270 | }; 271 | 272 | verb = "departed"; 273 | roomName = previousRoomName; 274 | } 275 | 276 | // console.log(`${participantName} ${verb} ${roomName}`); 277 | emitter.emit("room-change", participantName, verb, roomName); 278 | }); 279 | emitter.on("room-note-data", async (entity) => { 280 | let { id, content, updatedTimestamp } = entity; 281 | 282 | const room = zoomRooms.find( 283 | ({ noteBlockRCTogetherId }) => id === noteBlockRCTogetherId, 284 | ); 285 | 286 | if (!room?.roomName) return; 287 | 288 | roomNameToNote[room.roomName] = { 289 | content: content ?? "", 290 | date: new Date(updatedTimestamp), 291 | }; 292 | 293 | emitter.emit("room-change", "RCTogether", "updated", room.roomName); 294 | }); 295 | 296 | connect(actionCableAppId, actionCableAppSecret, emitter); 297 | 298 | const oauthClient = new OAuth2Client( 299 | clientId, 300 | authorizeEndpoint, 301 | tokenEndpoint, 302 | { 303 | redirectURI: `${baseURL}/myOauth2RedirectUri`, 304 | }, 305 | ); 306 | 307 | const adapter = new NodePostgresAdapter(sql, { 308 | user: "auth_user", 309 | session: "user_session", 310 | }); 311 | 312 | const lucia = new Lucia(adapter, { 313 | sessionExpiresIn: new TimeSpan(2, "w"), 314 | sessionCookie: { 315 | name: "rcverse-session", 316 | attributes: { 317 | // set to `true` when using HTTPS 318 | secure: process.env.NODE_ENV === "production", 319 | sameSite: process.env.NODE_ENV === "production" ? "lax" : "lax", 320 | // https://stackoverflow.com/a/1188145 321 | domain: process.env.NODE_ENV === "production" ? baseDomain : "", 322 | }, 323 | }, 324 | getSessionAttributes: (attributes) => { 325 | return { refresh_token: attributes.refresh_token }; 326 | }, 327 | }); 328 | app.use(express.urlencoded({ extended: true })); 329 | const corsMiddleware = (req, res, next) => { 330 | if (req.method === "GET") { 331 | return next(); 332 | } 333 | const originHeader = req.headers.origin ?? null; 334 | // NOTE: You may need to use `X-Forwarded-Host` instead 335 | const hostHeader = req.headers.host ?? null; 336 | if ( 337 | !originHeader || 338 | !hostHeader || 339 | // TODO: Adding the `baseDomain` into this list, but is that secure? 340 | // Without this, error arose when we switched from fly.dev to 341 | // recurse.com - likely because the Recurse.com proxy is rewriting 342 | // the hostHeader to fly.dev. 343 | // I wish it added a header like `X-Forwarded-Host` as it did that, 344 | // as suggested by the NOTE from the Lucia docs I copy&pasted above 345 | !verifyRequestOrigin(originHeader, [hostHeader, baseDomain]) 346 | ) { 347 | return res.status(403).end(); 348 | } 349 | 350 | return next(); 351 | }; 352 | app.use(corsMiddleware); 353 | 354 | const getSessionFromCookieMiddleware = async (req, res, next) => { 355 | const sessionId = lucia.readSessionCookie(req.headers.cookie ?? ""); 356 | req.locals = {}; 357 | if (!sessionId) { 358 | req.locals.user = null; 359 | req.locals.session = null; 360 | return next(); 361 | } 362 | 363 | const { session, user } = await lucia.validateSession(sessionId); 364 | if (session && session.fresh) { 365 | res.appendHeader( 366 | "Set-Cookie", 367 | lucia.createSessionCookie(session.id).serialize(), 368 | ); 369 | } 370 | if (!session) { 371 | res.appendHeader( 372 | "Set-Cookie", 373 | lucia.createBlankSessionCookie().serialize(), 374 | ); 375 | } 376 | req.locals.user = user; 377 | req.locals.session = session; 378 | return next(); 379 | }; 380 | app.use(getSessionFromCookieMiddleware); 381 | 382 | const isSessionAuthenticatedMiddleware = async (req, res, next) => { 383 | const { token } = req.query; 384 | if (!req.locals.session && !token) return next(); 385 | 386 | if (token === secretAuthToken) { 387 | req.locals.authenticated = true; 388 | return next(); 389 | } 390 | 391 | req.locals.authenticated = false; 392 | if (!req.locals.session?.refresh_token) return next(); 393 | 394 | try { 395 | // Again the oslo docs are wrong, or at least inspecific. 396 | // Source don't lie, though! https://github.com/pilcrowOnPaper/oslo/blob/main/src/oauth2/index.ts#L76 397 | const { access_token, refresh_token } = 398 | await oauthClient.refreshAccessToken(req.locals.session?.refresh_token, { 399 | credentials: clientSecret, 400 | authenticateWith: "request_body", 401 | }); 402 | 403 | await sql.query( 404 | "update user_session set refresh_token = $1 where id = $2", 405 | [refresh_token, req.locals.session?.id], 406 | ); 407 | 408 | req.locals.authenticated = true; 409 | req.locals.access_token = access_token; 410 | } catch (e) { 411 | if (e instanceof OAuth2RequestError) { 412 | // see https://www.rfc-editor.org/rfc/rfc6749#section-5.2 413 | const { request, message, description } = e; 414 | 415 | if (message === "invalid_grant") { 416 | console.log( 417 | "A user's authentication was rejected due to an invalid grant.", 418 | ); 419 | return next(); 420 | } 421 | } 422 | 423 | console.error("Invalidating old session due to error", e); 424 | await lucia.invalidateSession(req.locals.session?.id); 425 | res.appendHeader( 426 | "Set-Cookie", 427 | lucia.createBlankSessionCookie().serialize(), 428 | ); 429 | } 430 | return next(); 431 | }; 432 | 433 | const getRcUserMiddleware = async (req, res, next) => { 434 | if (!req.locals?.authenticated || !req.locals.access_token) return next(); 435 | 436 | const fetchResponse = await fetch( 437 | "https://www.recurse.com/api/v1/profiles/me", 438 | { 439 | headers: { 440 | Authorization: `Bearer ${req.locals.access_token}`, 441 | }, 442 | }, 443 | ); 444 | 445 | try { 446 | const json = await fetchResponse.json(); 447 | req.locals.rcPersonName = json.name; 448 | req.locals.rcUserId = String(json.id); 449 | } catch (error) { 450 | console.error( 451 | "Error getting RC user profile. Error (followed by response):", 452 | ); 453 | console.error(error); 454 | console.error("Response:"); 455 | console.error(await fetchResponse.text()); 456 | } 457 | 458 | return next(); 459 | }; 460 | 461 | const redirectToLoginIfNotAuthenticated = async (req, res, next) => { 462 | if (req.locals?.authenticated) return next(); 463 | 464 | res.redirect("/login"); 465 | }; 466 | 467 | const hxBlockIfNotAuthenticated = async (req, res, next) => { 468 | if (req.locals?.authenticated && req.locals.access_token) return next(); 469 | 470 | res.appendHeader("HX-Redirect", "/login?reason=deauthenticated"); 471 | return res.status(401).send( 472 | Page({ 473 | title: "RCVerse", 474 | body: Login({}), 475 | mixpanelToken, 476 | myRcUserId: req.locals.rcUserId, 477 | }), 478 | ); 479 | }; 480 | 481 | // We track who is at the hub in two different ways. We get immediate updates 482 | // through the action cable stream, but that doesn't help us understand who 483 | // came into the hub before the server started. So we sometimes also get the 484 | // info directly from the API which is conclusive 485 | let needToUpdateWhosAtTheHub = true; 486 | let timeoutScheduleNeedToUpdateWhosAtTheHub; 487 | const scheduleNeedToUpdateWhosAtTheHub = async () => { 488 | clearTimeout(timeoutScheduleNeedToUpdateWhosAtTheHub); 489 | await new Promise( 490 | (resolve) => 491 | (timeoutScheduleNeedToUpdateWhosAtTheHub = setTimeout( 492 | resolve, 493 | 1000 * 60 * 30, // 30 min 494 | )), 495 | ); 496 | needToUpdateWhosAtTheHub = true; 497 | }; 498 | 499 | const updateWhoIsAtTheHubMiddleware = async (req, res, next) => { 500 | if (!req.locals?.authenticated || !req.locals.access_token) return next(); 501 | if (!needToUpdateWhosAtTheHub) return next(); 502 | 503 | const date = getTodayDateForHubVisitsAPI(); 504 | const fetchResponse = await fetch( 505 | `https://www.recurse.com/api/v1/hub_visits?per_page=200&date=${date}`, 506 | { 507 | headers: { 508 | Authorization: `Bearer ${req.locals.access_token}`, 509 | }, 510 | }, 511 | ); 512 | 513 | const json = await fetchResponse.json(); 514 | Object.keys(participantNameToEntity).forEach((participantName) => { 515 | participantNameToEntity[participantName].inTheHub = false; 516 | }); 517 | const profilesToGet = []; 518 | json.forEach(({ person }) => { 519 | profilesToGet.push(person); 520 | if (!participantNameToEntity[person.name]) { 521 | participantNameToEntity[person.name] = {}; 522 | } 523 | participantNameToEntity[person.name] = { 524 | ...participantNameToEntity[person.name], 525 | inTheHub: true, 526 | }; 527 | }); 528 | 529 | await Promise.all( 530 | profilesToGet.map(async ({ id, name }) => { 531 | const fetchResponse = await fetch( 532 | `https://www.recurse.com/api/v1/profiles/${id}`, 533 | { 534 | headers: { 535 | Authorization: `Bearer ${req.locals.access_token}`, 536 | }, 537 | }, 538 | ); 539 | const { image_path, stints } = await fetchResponse.json(); 540 | if (!participantNameToEntity[name]) { 541 | participantNameToEntity[name] = {}; 542 | } 543 | 544 | const lastBatch = stints?.[0]?.batch?.short_name ?? ""; 545 | 546 | participantNameToEntity[name] = { 547 | ...participantNameToEntity[name], 548 | faceMarkerImagePath: image_path, 549 | lastBatch, 550 | }; 551 | }), 552 | ); 553 | 554 | inTheHubParticipantNames = profilesToGet.map(({ name }) => name); 555 | emitter.emit("in-the-hub-change"); 556 | needToUpdateWhosAtTheHub = false; 557 | scheduleNeedToUpdateWhosAtTheHub(); 558 | return next(); 559 | }; 560 | const personalizationsCookieName = "rcverse-personalizations"; 561 | // TODO: Must find a different strategy than relying on long term cookies alone 562 | // as cookies can't have a max age beyond 400 days. See 563 | // https://developer.chrome.com/blog/cookie-max-age-expires/ 564 | const personalizationsCookieMaxAge = Math.pow(2, 31) - 1; 565 | // TODO I don't know where to write this 566 | // Client could lose Websocket connection for a long time, like if they close their laptop 567 | // HTMX is going to try to reconnect immediately, but it doesn't do anything 568 | // to refresh the whole page to get a real understanding of the current world, 569 | // instead it will just start updating from the next stream evenst, and it could 570 | // be completley wrong about where everyone is currenlty 571 | app.get( 572 | "/", 573 | isSessionAuthenticatedMiddleware, 574 | redirectToLoginIfNotAuthenticated, 575 | getRcUserMiddleware, 576 | updateWhoIsAtTheHubMiddleware, 577 | async (req, res) => { 578 | let { sort, personalize } = req.query; 579 | 580 | // If `personalize` appears once in query string, it's a string. If it 581 | // appears multiple times, it's an array of strings. Coerce to one shape 582 | if (Array.isArray(personalize)) { 583 | personalize = personalize.map((url) => ({ url, cache: false })); 584 | } else { 585 | const url = personalize; 586 | personalize = []; 587 | if (url) personalize.push({ url, cache: false }); 588 | } 589 | 590 | // `?sort=none` uses the default ordering instead of sort by count 591 | const sortRooms = sort !== "none"; 592 | 593 | // `?reset` temporarily disables all saved personalizations 594 | const reset = req.query.hasOwnProperty("reset"); 595 | 596 | // TODO: If any includes are in the URL, then we redirect to a page 597 | // to manage and accept your new URLs with disclaimers and such. 598 | // Then, if the user accepts the disclaimers, we add that include to 599 | // their cookie so they can simply use the page rcverse.recurse.com 600 | // and it will load the exact same things. 601 | // But if we're making a separate page anyways, why not allow all 602 | // management on that page, isntead of starting with this fancy 603 | // and confusing query string thing. 604 | // So let's do that. 605 | 606 | // TODO: Add query parameters js+, css+, etc so that people can share links 607 | // to RCVerse that automatically add the scripts they like. "Share links" 608 | // TODO: Also add a button to copy these share links to the customization page. 609 | // TODO: Add a Share All button to customization page 610 | /** 611 | *

    612 | * To include custom snippets via URL, add the query parameter 613 | * ?include=<URL>. You can add this query parameter any 614 | * number of times, e.g. 615 | * ?include=<URL_1>&include=<URL_2>. 616 | *

    617 | */ 618 | 619 | const personalizations = []; 620 | 621 | if (!reset) { 622 | personalizations.push(...getPersonalizationsFromReqCookies(req)); 623 | } 624 | 625 | personalizations.push(...personalize); 626 | 627 | // TODO: Always set the personalizations cookie to update maxAge to 628 | // today + max maxAge since cookies have a max time of a year. So 629 | // this cookie should not expire until (max maxAge + last time you 630 | // loaded the page). BUT shouldn't set any cookie if you didn't have 631 | // any cookie set already 632 | 633 | res.send( 634 | Page({ 635 | title: "RCVerse", 636 | body: RootBody( 637 | mungeRootBody({ 638 | roomListContent: RoomList( 639 | mungeRoomList({ 640 | zoomRooms, 641 | roomNameToParticipantNames, 642 | participantNameToEntity, 643 | roomNameToNote, 644 | myParticipantName: req.locals.rcPersonName, 645 | inTheHubParticipantNames, 646 | sortRooms, 647 | locationToNowAndNextEvents, 648 | }), 649 | ), 650 | personalizations, 651 | }), 652 | ), 653 | mixpanelToken, 654 | myRcUserId: req.locals.rcUserId, 655 | }), 656 | ); 657 | }, 658 | ); 659 | 660 | const mungeLogin = ({ reason }) => { 661 | return { reason }; 662 | }; 663 | app.get("/login", async (req, res) => { 664 | const { reason } = req.query; 665 | res.send( 666 | Page({ 667 | title: "RCVerse", 668 | body: Login(mungeLogin({ reason })), 669 | mixpanelToken, 670 | myRcUserId: req.locals.rcUserId, 671 | }), 672 | ); 673 | }); 674 | 675 | app.ws("/websocket", async function (ws, req) { 676 | // TODO: Split up authentication mechanism instead of calling it with these 677 | // fake res and next 678 | await isSessionAuthenticatedMiddleware( 679 | req, 680 | { appendHeader: () => {} }, 681 | () => {}, 682 | ); 683 | if (!req.locals.authenticated) { 684 | ws.send("I'm afraid I can't do that Hal"); 685 | ws.close(); 686 | return; 687 | } 688 | 689 | await getRcUserMiddleware(req, { appendHeader: () => {} }, () => {}); 690 | 691 | let { sort } = req.query; 692 | 693 | // `?sort=none` uses the default ordering instead of sort by count 694 | // TODO: This logic is repeated from above, move into munge 695 | const sortRooms = sort !== "none"; 696 | 697 | // TODO: Somehow distinguish between the first connection on page load and 698 | // a reconnection, and don't send this on the first connection 699 | // TODO: Somehow distinguish if any messages were actually missed on reconnection 700 | // and only do this if any missed messages 701 | // TODO: Add more complicated solution which replays chunks of missed messages 702 | // instead of refreshing so much of the page 703 | ws.send( 704 | RoomList( 705 | mungeRoomList({ 706 | zoomRooms, 707 | roomNameToParticipantNames, 708 | participantNameToEntity, 709 | roomNameToNote, 710 | myParticipantName: req.locals.rcPersonName, 711 | inTheHubParticipantNames, 712 | sortRooms, 713 | locationToNowAndNextEvents, 714 | }), 715 | ), 716 | ); 717 | 718 | // NOTE: Only use async listeners, so that each listener doesn't block. 719 | const roomListener = async (participantName, action, roomName) => { 720 | ws.send( 721 | Room( 722 | mungeRoom({ 723 | roomName, 724 | roomLocation: zoomRoomsByName[roomName].location, 725 | roomNameToNote, 726 | roomNameToParticipantNames, 727 | participantNameToEntity, 728 | locationToNowAndNextEvents, 729 | }), 730 | ), 731 | ); 732 | }; 733 | 734 | const inTheHubListener = async () => { 735 | ws.send( 736 | WhoIsInTheHub( 737 | mungeWhoIsInTheHub({ 738 | inTheHubParticipantNames, 739 | participantNameToEntity, 740 | myParticipantName: req.locals.rcPersonName, 741 | }), 742 | ), 743 | ); 744 | }; 745 | const keepWsAliveListener = () => { 746 | // TODO: How to send an empty message that doesn't conflict with HTMX? 747 | ws.send("
    "); 748 | }; 749 | 750 | emitter.on("room-change", roomListener); 751 | emitter.on("in-the-hub-change", inTheHubListener); 752 | emitter.on("keep-ws-alive", keepWsAliveListener); 753 | 754 | // If client closes connection, stop sending events 755 | ws.on("close", () => { 756 | emitter.off("room-change", roomListener); 757 | emitter.off("in-the-hub-change", inTheHubListener); 758 | emitter.off("keep-ws-alive", keepWsAliveListener); 759 | }); 760 | }); 761 | 762 | // After seeing reference to another service with a 60 second idle timeout for 763 | // websocket connections (https://stackoverflow.com/a/48764819), I thought I 764 | // might try the same solution and see if that fixed the issue despite this 765 | // being on a different stack. 766 | // TODO: Test and see if the websocket still closes after 55 seconds 767 | setInterval(() => { 768 | emitter.emit("keep-ws-alive"); 769 | }, 1000 * 30); 770 | 771 | const roomNameToNote = {}; 772 | app.post( 773 | "/note", 774 | isSessionAuthenticatedMiddleware, 775 | hxBlockIfNotAuthenticated, 776 | async function (req, res) { 777 | const { room, content } = req.body; 778 | 779 | const zoomRoom = zoomRooms.find(({ roomName }) => room === roomName); 780 | 781 | if (!zoomRoom) { 782 | console.error( 783 | `Delete this message, but failed to find zoom room for name '${room}'`, 784 | ); 785 | } 786 | 787 | const { noteBlockRCTogetherId } = zoomRoom; 788 | 789 | // TODO: Now we need coordinates for a free square next to a note block 790 | // Just going to hard code these for the moment, because there's no pathfinding available. 791 | const noteBlockRCTogetherIdToAdjacentFreeSpacePositions = { 792 | 152190: { x: 68, y: 58 }, // Pairing station 1 793 | 152189: { x: 68, y: 51 }, // Pairing station 2 794 | 152193: { x: 64, y: 48 }, // Pairing station 3 795 | 152191: { x: 65, y: 43 }, // Pairing station 4 796 | 140538: { x: 70, y: 43 }, // Pairing station 5 797 | 152198: { x: 74, y: 43 }, // Pairing station 6 798 | 152192: { x: 77, y: 43 }, // Pairing station 7 799 | 81750: { x: 85, y: 56 }, // Couches 800 | }; 801 | 802 | const coords = 803 | noteBlockRCTogetherIdToAdjacentFreeSpacePositions[noteBlockRCTogetherId]; 804 | 805 | if (!noteBlockRCTogetherId || !coords) { 806 | // There is no VirtualRC block associated with this room, so just update 807 | // local data. This is totally normal for non-pairing station rooms 808 | if (!content) { 809 | delete roomNameToNote[room]; 810 | } else { 811 | roomNameToNote[room] = { 812 | content: escapeHtml(content) ?? "", 813 | date: new Date(), 814 | }; 815 | } 816 | emitter.emit("room-change", "someone", "updated the note for", room); 817 | return; 818 | } 819 | 820 | const baseUrl = `https://recurse.rctogether.com/api`; 821 | const botEndpoint = `${baseUrl}/bots/`; 822 | const noteEndpoint = `${baseUrl}/notes/`; 823 | const authParams = `app_id=${actionCableAppId}&app_secret=${actionCableAppSecret}`; 824 | const headers = { 825 | "Content-Type": "application/json", 826 | }; 827 | 828 | // TODO: Remove this - it's a testing utility because if something goes 829 | // wrong in this flow, my bot would not be properly deleted and I'd 830 | // have to delete it on the next experiment 831 | await fetch(botEndpoint + "155487" + "?" + authParams, { 832 | method: "DELETE", 833 | headers, 834 | }); 835 | let botId; 836 | try { 837 | let apiResult = await fetch(botEndpoint + "?" + authParams, { 838 | method: "POST", 839 | headers, 840 | body: JSON.stringify({ 841 | name: "RCVerse", 842 | x: coords.x, 843 | y: coords.y, 844 | emoji: "✏️", 845 | }), 846 | }); 847 | 848 | const botCreated = await apiResult.json(); 849 | // console.log("Bot created with ID #" + botCreated.id); 850 | if (!botCreated.id) { 851 | console.log("Bot creation failed somehow"); 852 | console.log(apiResult.status); 853 | console.log(apiResult.statusText); 854 | console.log(botCreated); 855 | } 856 | botId = String(botCreated.id); 857 | 858 | // Even if there's a problem with writing the note, we still want to 859 | // catch and cleanup the bot 860 | try { 861 | // Update the note 862 | apiResult = await fetch( 863 | noteEndpoint + noteBlockRCTogetherId + "?" + authParams, 864 | { 865 | method: "PATCH", 866 | headers, 867 | body: JSON.stringify({ 868 | bot_id: botCreated.id, 869 | note: { 870 | note_text: content, 871 | }, 872 | }), 873 | }, 874 | ); 875 | } catch (errorNote) { 876 | console.error("Error with editing the note in VirtualRC", error); 877 | } 878 | 879 | // Delete the bot 880 | await fetch(botEndpoint + botId + "?" + authParams, { 881 | method: "DELETE", 882 | headers, 883 | }); 884 | } catch (error) { 885 | console.error("Error with VirtualRC note-writing bot", error); 886 | res.status(500).end(); 887 | return; 888 | } 889 | // console.log(`Room '${room}' note changed to ${content} (pre-escape)`); 890 | 891 | // Only if the update in RCTogether was ostensibly successful do we 892 | // optimistically update our own version. 893 | // TODO: Disabling optimistic update, just waiting for ActionCable round trip instead 894 | // TODO: When I re-enable, be mindful of the duplicated logic that does real markdown work - probably extract to function 895 | // if (!content) { 896 | // delete roomNameToNote[room]; 897 | // } else { 898 | // roomNameToNote[room] = { 899 | // content: escapeHtml(content) ?? "", 900 | // date: new Date(), 901 | // }; 902 | // } 903 | // emitter.emit("room-change", "someone", "updated the note for", room); 904 | 905 | res.status(200).end(); 906 | }, 907 | ); 908 | 909 | const mungeEditNoteForm = ({ roomName, roomNameToNote, zoomRooms }) => ({ 910 | roomName, 911 | noteContent: roomNameToNote[roomName]?.content ?? "", 912 | hasVirtualRCConnectedBlock: Boolean( 913 | zoomRooms.find(({ roomName: zoomRoomName }) => zoomRoomName === roomName) 914 | ?.noteBlockRCTogetherId, 915 | ), 916 | }); 917 | 918 | app.get("/note.html", isSessionAuthenticatedMiddleware, function (req, res) { 919 | const { roomName } = req.query; 920 | 921 | res.send( 922 | EditNoteForm(mungeEditNoteForm({ roomName, roomNameToNote, zoomRooms })), 923 | ); 924 | }); 925 | 926 | // Every hour, clean up any notes which haven't been updated for 4 hours 927 | function cleanNotes() { 928 | const millisNow = Date.now(); 929 | Object.keys(roomNameToNote).forEach((roomName) => { 930 | if (!roomNameToNote[roomName]) return; 931 | const content = roomNameToNote[roomName]?.content; 932 | if (typeof content === "string" && !content.match(/\S/)) { 933 | roomNameToNote[roomName] = null; 934 | } 935 | const date = roomNameToNote[roomName]?.date; 936 | if (!date) return; 937 | const millisThen = date.getTime(); 938 | const difference = millisNow - millisThen; 939 | if (difference < 1000 * 60 * 60 * 4) return; // 4 hours 940 | roomNameToNote[roomName] = null; 941 | }); 942 | 943 | setTimeout(cleanNotes, 1000 * 60 * 60); // 1 hour 944 | } 945 | // TODO: Disabled the note cleaning function (we never start it) after connecting 946 | // the RCTogether room notes to the RCverse notes. The norm on RCTogether 947 | // has been to reset the note to be non-empty 948 | // (kinda like a form "Pairing on: Open invite: ?") We could do that, and 949 | // we could store the default note (because it's different for pairing 950 | // stations than for couches) in the SQL table next to the note ID 951 | // TODO: In order to re-enable cleaning notes, must use the bot process for 952 | // updating the notes. Probably want to use one bot to clean multiple 953 | // notes? 954 | // cleanNotes(); 955 | 956 | // We only need to update from the remote rarely, since the calendar doesn't 957 | // change quickly very often (people rarely cancel things last minute) 958 | // But we want to update the "starts in 5 minutes" quotes very often throughout 959 | // the day, so make two separate loops, one for refreshing from remote, and one 960 | // for refreshing from the current information 961 | // TODO: Could make a button from the front-end to trigger a refresh manually 962 | let locationToNowAndNextEvents = {}; 963 | let iCalendar = {}; 964 | async function updateCalendarFromRemote() { 965 | try { 966 | iCalendar = await ical.async.fromURL( 967 | `https://www.recurse.com/calendar/events.ics?token=${recurseCalendarToken}&omit_cancelled_events=1&scope=all`, 968 | ); 969 | } catch (error) { 970 | console.error("Failed to fetch calendar ICS, will try again. Error:"); 971 | console.error(error); 972 | } 973 | clearTimeout(timeoutIdForUpdateRoomsAsCalendarEventsChangeOverTime); 974 | updateRoomsAsCalendarEventsChangeOverTime(); 975 | 976 | setTimeout(updateCalendarFromRemote, 1000 * 60 * 30); 977 | } 978 | updateCalendarFromRemote(); 979 | 980 | let timeoutIdForUpdateRoomsAsCalendarEventsChangeOverTime; 981 | const calendarUpdateDelay = 1000 * 60 * 5; 982 | function updateRoomsAsCalendarEventsChangeOverTime() { 983 | const now = new Date(); 984 | const nowPlusCalendarDelay = new Date(); 985 | nowPlusCalendarDelay.setTime( 986 | nowPlusCalendarDelay.getTime() + calendarUpdateDelay, 987 | ); 988 | const tomorrow = new Date(); 989 | tomorrow.setTime(tomorrow.getTime() + 1000 * 60 * 60 * 24); 990 | const yesterday = new Date(); 991 | yesterday.setTime(yesterday.getTime() - 1000 * 60 * 60 * 24); 992 | const soonish = new Date(); 993 | soonish.setTime(soonish.getTime() + 1000 * 60 * 80); // 80 minutes 994 | const locationToEvents = {}; 995 | Object.entries(iCalendar).forEach(([_, event]) => { 996 | const { location, start, end } = event; 997 | let keep = true; 998 | keep &&= event.type === "VEVENT"; 999 | keep &&= location in zoomRoomsByLocation; 1000 | keep &&= start >= yesterday; // Started less than 24 hours ago 1001 | keep &&= end <= tomorrow; // Ends less than 24 hours from now 1002 | keep &&= now <= end; // Hasn't ended yet 1003 | if (!keep) return; 1004 | 1005 | if (!locationToEvents[location]) { 1006 | locationToEvents[location] = []; 1007 | } 1008 | locationToEvents[location].push(event); 1009 | }); 1010 | 1011 | // Before we drop the old events object, record which rooms had an event 1012 | const roomNamesWithEvents = new Set(); 1013 | Object.entries(locationToNowAndNextEvents).forEach( 1014 | ([location, { now, next }]) => { 1015 | if (now.length === 0 && next.length === 0) return; 1016 | roomNamesWithEvents.add(zoomRoomsByLocation[location].roomName); 1017 | }, 1018 | ); 1019 | 1020 | locationToNowAndNextEvents = {}; 1021 | Object.entries(locationToEvents).forEach(([location, events]) => { 1022 | events.sort((a, b) => a.start - b.start); 1023 | 1024 | locationToNowAndNextEvents[location] = { 1025 | now: [], 1026 | next: [], 1027 | }; 1028 | 1029 | events.forEach((event) => { 1030 | const { start } = event; 1031 | 1032 | if (start <= nowPlusCalendarDelay) { 1033 | locationToNowAndNextEvents[location].now.push(event); 1034 | roomNamesWithEvents.add(zoomRoomsByLocation[location].roomName); 1035 | } else if (start <= soonish) { 1036 | locationToNowAndNextEvents[location].next.push(event); 1037 | roomNamesWithEvents.add(zoomRoomsByLocation[location].roomName); 1038 | } 1039 | }); 1040 | }); 1041 | 1042 | roomNamesWithEvents.forEach((roomName) => { 1043 | emitter.emit("room-change", "events", "changed for", roomName); 1044 | }); 1045 | 1046 | // This isn't necessary right now, but possible source of a confusing bug 1047 | clearTimeout(timeoutIdForUpdateRoomsAsCalendarEventsChangeOverTime); 1048 | timeoutIdForUpdateRoomsAsCalendarEventsChangeOverTime = setTimeout( 1049 | updateRoomsAsCalendarEventsChangeOverTime, 1050 | calendarUpdateDelay, 1051 | ); 1052 | } 1053 | 1054 | app.post( 1055 | `/checkIntoHub`, 1056 | isSessionAuthenticatedMiddleware, 1057 | hxBlockIfNotAuthenticated, 1058 | getRcUserMiddleware, 1059 | async (req, res) => { 1060 | const { note } = req.body; 1061 | const date = getTodayDateForHubVisitsAPI(); 1062 | await fetch( 1063 | `https://www.recurse.com/api/v1/hub_visits/${req.locals.rcUserId}/${date}`, 1064 | { 1065 | method: "PATCH", 1066 | headers: { 1067 | Authorization: `Bearer ${req.locals.access_token}`, 1068 | }, 1069 | body: { 1070 | notes: note, 1071 | }, 1072 | }, 1073 | ); 1074 | res.sendStatus(200); 1075 | }, 1076 | ); 1077 | 1078 | app.get( 1079 | "/personalization", 1080 | isSessionAuthenticatedMiddleware, 1081 | hxBlockIfNotAuthenticated, 1082 | function (req, res) { 1083 | const personalizations = getPersonalizationsFromReqCookies(req); 1084 | 1085 | res.send( 1086 | Page({ 1087 | title: "RCVerse Personalizations", 1088 | body: Personalization( 1089 | mungePersonalization({ 1090 | personalizations, 1091 | defaultPersonalizations: DEFAULT_PERSONALIZATIONS, 1092 | }), 1093 | ), 1094 | mixpanelToken, 1095 | myRcUserId: req.locals.rcUserId, 1096 | }), 1097 | ); 1098 | }, 1099 | ); 1100 | 1101 | app.post( 1102 | "/personalization", 1103 | isSessionAuthenticatedMiddleware, 1104 | hxBlockIfNotAuthenticated, 1105 | function (req, res) { 1106 | const { 1107 | addUrl, 1108 | removeUrl, 1109 | url, 1110 | index: indexString, 1111 | moveItemUp, 1112 | moveItemDown, 1113 | reset, 1114 | reallyReset, 1115 | cache, 1116 | } = req.body; 1117 | let personalizations = getPersonalizationsFromReqCookies(req); 1118 | const index = parseInt(indexString); 1119 | 1120 | if (Number.isInteger(index)) { 1121 | personalizations[index].cache = cache === "on"; 1122 | } 1123 | 1124 | if (addUrl) { 1125 | personalizations.push({ url: addUrl.trim(), cache: true }); 1126 | } 1127 | 1128 | if (removeUrl) { 1129 | personalizations = personalizations.filter((p) => p.url !== url.trim()); 1130 | } 1131 | 1132 | if (moveItemUp) { 1133 | const tmp = personalizations[index - 1]; 1134 | personalizations[index - 1] = personalizations[index]; 1135 | personalizations[index] = tmp; 1136 | } 1137 | 1138 | if (moveItemDown) { 1139 | const tmp = personalizations[index + 1]; 1140 | personalizations[index + 1] = personalizations[index]; 1141 | personalizations[index] = tmp; 1142 | } 1143 | 1144 | if (reset && reallyReset === "confirm") { 1145 | // Null isn't an array, resets to current defaults 1146 | personalizations = null; 1147 | } 1148 | 1149 | res.appendHeader( 1150 | "Set-Cookie", 1151 | new Cookie(personalizationsCookieName, JSON.stringify(personalizations), { 1152 | maxAge: personalizationsCookieMaxAge, 1153 | }).serialize(), 1154 | ); 1155 | 1156 | // Redirect instead of rendering the page again, Post-Redirect-Get 1157 | // See https://en.wikipedia.org/wiki/Post/Redirect/Get 1158 | res.redirect("/personalization"); 1159 | }, 1160 | ); 1161 | 1162 | // Data mungers take the craziness of the internal data structures 1163 | // and make them peaceful and clean for the HTML generator 1164 | const mungeRootBody = ({ roomListContent, personalizations }) => { 1165 | return { 1166 | roomListContent, 1167 | personalizations, 1168 | }; 1169 | }; 1170 | 1171 | const mungeRoomList = ({ 1172 | zoomRooms, 1173 | roomNameToParticipantNames, 1174 | participantNameToEntity, 1175 | roomNameToNote, 1176 | myParticipantName, 1177 | inTheHubParticipantNames, 1178 | sortRooms, 1179 | locationToNowAndNextEvents, 1180 | }) => { 1181 | const whoIsInTheHub = mungeWhoIsInTheHub({ 1182 | inTheHubParticipantNames, 1183 | participantNameToEntity, 1184 | myParticipantName, 1185 | }); 1186 | const rooms = zoomRooms.map(({ roomName }) => 1187 | mungeRoom({ 1188 | roomName, 1189 | roomLocation: zoomRoomsByName[roomName].location, 1190 | roomNameToNote, 1191 | roomNameToParticipantNames, 1192 | participantNameToEntity, 1193 | locationToNowAndNextEvents, 1194 | }), 1195 | ); 1196 | 1197 | if (sortRooms) { 1198 | const inAYear = new Date(); 1199 | inAYear.setTime(inAYear.getTime() + 1000 * 60 * 60 * 24 * 365); 1200 | rooms.sort((a, b) => { 1201 | // Primary sort by current or upcoming events 1202 | // earlier events with smaller "start" value -> appears first 1203 | // (note that this is the reverse of the above sort order) 1204 | const aNowEventStart = 1205 | locationToNowAndNextEvents[a.roomLocation]?.now?.[0]?.start; 1206 | const aNextEventStart = 1207 | locationToNowAndNextEvents[a.roomLocation]?.next?.[0]?.start; 1208 | const bNowEventStart = 1209 | locationToNowAndNextEvents[b.roomLocation]?.now?.[0]?.start; 1210 | const bNextEventStart = 1211 | locationToNowAndNextEvents[b.roomLocation]?.next?.[0]?.start; 1212 | 1213 | const comparison = 1214 | (aNowEventStart ?? aNextEventStart ?? inAYear) - 1215 | (bNowEventStart ?? bNextEventStart ?? inAYear); 1216 | 1217 | if (comparison !== 0) return comparison; 1218 | 1219 | // Secondary sort by participant count (more people -> appears first) 1220 | return b.count - a.count; 1221 | }); 1222 | } 1223 | 1224 | return { 1225 | whoIsInTheHub, 1226 | rooms, 1227 | }; 1228 | }; 1229 | 1230 | const mungeRoom = ({ 1231 | roomName, 1232 | roomLocation, 1233 | roomNameToNote, 1234 | roomNameToParticipantNames, 1235 | participantNameToEntity, 1236 | locationToNowAndNextEvents, 1237 | }) => { 1238 | return { 1239 | roomName, 1240 | roomLocation, 1241 | hasNote: Boolean(roomNameToNote[roomName]), 1242 | noteContent: roomNameToNote[roomName]?.content 1243 | ? marked.parse(escapeHtml(roomNameToNote[roomName]?.content ?? "")) 1244 | : "", 1245 | noteDateTime: roomNameToNote[roomName]?.date?.toISOString() ?? null, 1246 | noteHowManyMinutesAgo: howManyMinutesAgo(roomNameToNote[roomName]?.date), 1247 | isEmpty: (roomNameToParticipantNames[roomName]?.length ?? 0) == 0, 1248 | count: roomNameToParticipantNames[roomName]?.length || 0, 1249 | countPhrase: countPhrase(roomNameToParticipantNames[roomName]?.length || 0), 1250 | participants: mungeParticipants({ 1251 | participantNames: roomNameToParticipantNames[roomName] ?? [], 1252 | participantNameToEntity, 1253 | }), 1254 | hasNowEvent: locationToNowAndNextEvents[roomLocation]?.now?.[0], 1255 | nowEventName: locationToNowAndNextEvents[roomLocation]?.now?.[0]?.summary, 1256 | nowEventStartedHowManyMinutesAgo: howManyMinutesAgo( 1257 | locationToNowAndNextEvents[roomLocation]?.now?.[0]?.start, 1258 | ), 1259 | nowEventDateTime: 1260 | locationToNowAndNextEvents[ 1261 | roomLocation 1262 | ]?.now?.[0]?.start?.toISOString() ?? null, 1263 | nowEventCalendarUrl: 1264 | locationToNowAndNextEvents[roomLocation]?.now?.[0]?.url, 1265 | hasNextEvent: locationToNowAndNextEvents[roomLocation]?.next?.[0], 1266 | nextEventName: locationToNowAndNextEvents[roomLocation]?.next?.[0]?.summary, 1267 | nextEventStartsInHowLong: howLongInTheFuture( 1268 | locationToNowAndNextEvents[roomLocation]?.next?.[0]?.start, 1269 | ), 1270 | nextEventDateTime: 1271 | locationToNowAndNextEvents[ 1272 | roomLocation 1273 | ]?.next?.[0]?.start?.toISOString() ?? null, 1274 | nextEventCalendarUrl: 1275 | locationToNowAndNextEvents[roomLocation]?.next?.[0]?.url, 1276 | }; 1277 | }; 1278 | 1279 | const mungePersonalization = ({ 1280 | personalizations, 1281 | defaultPersonalizations, 1282 | }) => ({ 1283 | personalizations, 1284 | defaultPersonalizations, 1285 | }); 1286 | 1287 | const mungeParticipants = ({ participantNames, participantNameToEntity }) => { 1288 | return ( 1289 | participantNames.map((participantName) => ({ 1290 | participantName, 1291 | faceMarkerImagePath: 1292 | participantNameToEntity[participantName]?.faceMarkerImagePath ?? 1293 | "recurse-community-bot.png", 1294 | lastBatch: participantNameToEntity[participantName]?.lastBatch ?? "", 1295 | })) ?? [] 1296 | ); 1297 | }; 1298 | 1299 | const mungeWhoIsInTheHub = ({ 1300 | inTheHubParticipantNames, 1301 | participantNameToEntity, 1302 | myParticipantName, 1303 | }) => { 1304 | return { 1305 | isEmpty: inTheHubParticipantNames.length > 0, 1306 | participants: mungeParticipants({ 1307 | participantNames: inTheHubParticipantNames, 1308 | participantNameToEntity, 1309 | }), 1310 | iAmCheckedIn: participantNameToEntity[myParticipantName]?.inTheHub, 1311 | countPhrase: countPhrase(inTheHubParticipantNames.length || 0), 1312 | }; 1313 | }; 1314 | 1315 | app.get("/logout", async (req, res) => { 1316 | lucia.invalidateSession(req.locals.session?.id); 1317 | 1318 | res.redirect("/login"); 1319 | }); 1320 | 1321 | const oauthStateCookieName = "rc-verse-login-oauth-state"; 1322 | app.get("/getAuthorizationUrl", async (req, res) => { 1323 | const state = generateState(); 1324 | res.appendHeader( 1325 | "Set-Cookie", 1326 | new Cookie(oauthStateCookieName, state).serialize(), 1327 | ); 1328 | 1329 | const url = await oauthClient.createAuthorizationURL({ 1330 | state, 1331 | scope: ["user:email"], 1332 | }); 1333 | res.redirect(url); 1334 | }); 1335 | 1336 | app.get("/myOauth2RedirectUri", async (req, res) => { 1337 | const { state, code } = req.query; 1338 | 1339 | const cookies = parseCookies(req.headers.cookie); 1340 | const cookieState = cookies.get(oauthStateCookieName); 1341 | 1342 | if (!cookieState || !state || cookieState !== state) { 1343 | console.error("State didn't match", { cookieState, state }); 1344 | await lucia.invalidateSession(req.locals.session?.id); 1345 | res.appendHeader( 1346 | "Set-Cookie", 1347 | lucia.createBlankSessionCookie().serialize(), 1348 | ); 1349 | res.redirect("/"); 1350 | return; 1351 | } 1352 | 1353 | try { 1354 | // NOTE: This is different from the Oslo OAuth2 docs, they use camel case 1355 | const { access_token, refresh_token } = 1356 | await oauthClient.validateAuthorizationCode(code, { 1357 | credentials: clientSecret, 1358 | authenticateWith: "request_body", 1359 | }); 1360 | 1361 | // TODO Why do we even have a user table for this app? We want to use Recurse API's 1362 | // idea of a user. But we can't get that user ID until after a successful login 1363 | // so instead every time we create a new session we're just going to create a new 1364 | // user? There's no cleanup even. We should also be deleting every user when a 1365 | // session expires, but we're not yet. But even if we attempted to delete every 1366 | // user with no session, then we'd still probably have leaks. So instead we want a 1367 | // cleanup cron job 1368 | const userId = `${Date.now()}.${Math.floor(Math.random() * 10000)}`; 1369 | await sql.query(`insert into auth_user values ($1)`, [userId]); 1370 | const session = await lucia.createSession(userId, { 1371 | // Note: This has to be returned in "getSessionAttributes" in new Lucia(...) 1372 | // TODO: We can set these things once, but can we ever set them again? 1373 | refresh_token, 1374 | }); 1375 | 1376 | res.appendHeader( 1377 | "Set-Cookie", 1378 | lucia.createSessionCookie(session.id).serialize(), 1379 | ); 1380 | 1381 | // Also update the local copy of the session since the middleware might not run? 1382 | // TODO Try removing this and see if it still works 1383 | req.locals.session = session; 1384 | res.redirect("/"); 1385 | return; 1386 | } catch (e) { 1387 | if (e instanceof OAuth2RequestError) { 1388 | // see https://www.rfc-editor.org/rfc/rfc6749#section-5.2 1389 | const { request, message, description } = e; 1390 | } 1391 | // unknown error 1392 | console.error("Invalidating new session due to error", e); 1393 | await lucia.invalidateSession(req.locals.session?.id); 1394 | res.appendHeader( 1395 | "Set-Cookie", 1396 | lucia.createBlankSessionCookie().serialize(), 1397 | ); 1398 | res.redirect("/"); 1399 | } 1400 | }); 1401 | 1402 | app.use(express.static("public")); 1403 | 1404 | // 1405 | // Final 404/5XX handlers 1406 | // 1407 | app.use(function (err, req, res, next) { 1408 | console.error("5XX", err, req, next); 1409 | res.status(err?.status || 500); 1410 | 1411 | res.send("500"); 1412 | }); 1413 | 1414 | app.use(function (req, res) { 1415 | res.status(404); 1416 | res.send("404"); 1417 | }); 1418 | 1419 | const listener = app.listen(port, "::", () => { 1420 | console.log(`Server is available at ${baseURL}`); 1421 | }); 1422 | 1423 | // So I can kill from local terminal with Ctrl-c 1424 | // From https://github.com/strongloop/node-foreman/issues/118#issuecomment-475902308 1425 | process.on("SIGINT", () => { 1426 | listener.close(() => {}); 1427 | // Just wait some amount of time before exiting. Ideally the listener would 1428 | // close successfully, but it seems to hang for some reason. 1429 | setTimeout(() => process.exit(0), 150); 1430 | }); 1431 | // Typescript recommendation from https://lucia-auth.com/getting-started/ 1432 | // declare module "lucia" { 1433 | // interface Register { 1434 | // Lucia: typeof lucia; 1435 | // } 1436 | // } 1437 | 1438 | const getTodayDateForHubVisitsAPI = () => { 1439 | let date = new Date(); 1440 | date = date.toISOString(); 1441 | // Format date as `yyyy-mm-dd` 1442 | return date.slice(0, date.indexOf("T")); 1443 | }; 1444 | 1445 | // Minutes in milliseconds 1446 | const MIN = 1000 * 60; 1447 | const howManyMinutesAgo = (date) => { 1448 | if (!date) return null; 1449 | return formatDistanceToNow(date, { addSuffix: true }); 1450 | }; 1451 | 1452 | const howLongInTheFuture = (date) => { 1453 | if (!date) return null; 1454 | return formatDistanceToNow(date, { addSuffix: true }); 1455 | }; 1456 | 1457 | const countPhrase = (count) => { 1458 | return count === 0 ? "" : count === 1 ? "1 person" : `${count} people`; 1459 | }; 1460 | 1461 | const DEFAULT_PERSONALIZATIONS = [ 1462 | "/personalizations/hide-fouc.css", 1463 | "/personalizations/recurse-com__font-awesome.css", 1464 | "/personalizations/recurse-com__cherry-picked.css", 1465 | "/personalizations/recurse-com__header.html", 1466 | "/personalizations/icons.css", 1467 | "/personalizations/rcverse-base-style.css", 1468 | "/personalizations/register-service-worker.js", 1469 | "/personalizations/confetti-once.html", 1470 | "/personalizations/hannahs-colorful-rooms.css", 1471 | "/personalizations/show-fouc.css", 1472 | ].map((url) => { 1473 | return { url, cache: true }; 1474 | }); 1475 | 1476 | const getPersonalizationsFromReqCookies = (req) => { 1477 | let parsed; 1478 | try { 1479 | const cookies = parseCookies(req.headers.cookie); 1480 | const personalizationsCookie = cookies.get(personalizationsCookieName); 1481 | parsed = JSON.parse(personalizationsCookie); 1482 | } catch (error) { 1483 | /* Do nothing - it's either an array set intentionally or we'll reset it */ 1484 | } 1485 | if (!Array.isArray(parsed)) parsed = [...DEFAULT_PERSONALIZATIONS]; 1486 | 1487 | // TODO: Temporarily transform from the old shape, an array of string URLs, 1488 | // into an array of objects. Can delete in some future time? 1489 | return parsed.map((personalization) => 1490 | typeof personalization === "string" 1491 | ? { url: personalization, cache: true } 1492 | : personalization, 1493 | ); 1494 | }; 1495 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rcverse", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node --env-file=config.env index.js", 9 | "start:debug": "node --env-file=config.env --inspect index.js", 10 | "start:debug-brk": "node --env-file=config.env --inspect-brk index.js", 11 | "generate-cert": "sh ./scripts/generate-cert.sh" 12 | }, 13 | "type": "module", 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@lucia-auth/adapter-postgresql": "^3.1.1", 19 | "actioncable-nodejs": "^1.0.4", 20 | "date-fns": "^3.6.0", 21 | "express": "^4.18.3", 22 | "express-ws": "^5.0.2", 23 | "lucia": "^3.0.1", 24 | "marked": "^14.0.0", 25 | "node-ical": "^0.18.0", 26 | "oslo": "^1.1.3", 27 | "pg": "^8.11.3" 28 | }, 29 | "devDependencies": { 30 | "@stylistic/eslint-plugin": "^1.7.2", 31 | "eslint": "^9.1.0", 32 | "globals": "^15.0.0", 33 | "prettier": "^3.2.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reedspool/rcverse/08f91adea24aaea17958a579b2593ea2cc973353/public/favicon.ico -------------------------------------------------------------------------------- /public/personalizations.css: -------------------------------------------------------------------------------- 1 | /* Reset / General */ 2 | 3 | body { 4 | margin: 0; 5 | } 6 | 7 | p { 8 | max-width: 60ch; 9 | } 10 | 11 | button { 12 | cursor: pointer; 13 | } 14 | 15 | dd { 16 | margin-inline-start: 0px; 17 | } 18 | 19 | summary { 20 | cursor: pointer; 21 | } 22 | 23 | main { 24 | padding-inline: 1rem; 25 | } 26 | 27 | h1, 28 | h2, 29 | h3, 30 | h4, 31 | h5, 32 | h6 { 33 | font-weight: normal; 34 | } 35 | 36 | /* BEM - See https://getbem.com/introduction/ */ 37 | main.personalization { 38 | max-width: 100ch; 39 | margin: auto; 40 | display: flex; 41 | flex-direction: column; 42 | gap: 0.8em; 43 | } 44 | 45 | @media only screen and (max-width: 800px) { 46 | -------------------------------------------------------------------------------- /public/personalizations/add-room-name-copy-to-clipboard-button.js: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // https://rcverse.recurse.com/?reset 4 | // https://rcverse.recurse.com/?reset&personalize=https://cdn.jsdelivr.net/gh/izzee/rcverse-customizations/styles.html 5 | 6 | // 0. Run a local server like `npx local-web-server` 7 | // 1. Start with a check to see our personalization 8 | // http://127.0.0.1:8000/add-room-name-copy-to-clipboard-button.js 9 | // https://rcverse.recurse.com?personalize=http://127.0.0.1:8000/add-room-name-copy-to-clipboard-button.js 10 | // alert("It's alive!"); 11 | if (true) { 12 | const customize = (roomElement) => { 13 | // NEW 14 | const zoomLink = roomElement.querySelector("a[href*=zoom]"); 15 | if (!zoomLink) return; 16 | const textToCopy = `[${zoomLink.innerText}](${zoomLink.getAttribute("href")})`; 17 | zoomLink.insertAdjacentHTML( 18 | "afterend", 19 | ``, 22 | ); 23 | // END NEW 24 | }; 25 | document.body.addEventListener("htmx:afterSwap", (event) => { 26 | const roomElement = event.detail.target; 27 | if (!roomElement.matches(".room")) return; 28 | console.log("htmx after swap room", roomElement); 29 | customize(roomElement); 30 | }); 31 | document.body.addEventListener("htmx:wsAfterMessage", ({ detail }) => { 32 | const roomElement = document.querySelector( 33 | `#${detail.message.match(/id="([^"]+)"/)[1]}`, 34 | ); 35 | if (!roomElement) return; 36 | console.log("htmx wsaftermessage", roomElement); 37 | customize(roomElement); 38 | }); 39 | window.addEventListener("DOMContentLoaded", () => { 40 | document.querySelectorAll(".room").forEach(customize); 41 | }); 42 | } 43 | 44 | // Finally, push to GitHub and Add personalization link via jsDelivr 45 | // https://cdn.jsdelivr.net/gh/reedspool/rcverse/public/personalizations/add-room-name-copy-to-clipboard-button.js 46 | -------------------------------------------------------------------------------- /public/personalizations/confetti-once.html: -------------------------------------------------------------------------------- 1 | If you see the confetti, you can just refresh the page to stop it! Hit this 2 | button if you want to turn it back on. 3 | 4 | 5 | 17 | 18 | 19 | 132 | -------------------------------------------------------------------------------- /public/personalizations/hannahs-colorful-rooms.css: -------------------------------------------------------------------------------- 1 | #room-update-Couches .room__header { 2 | background-image: linear-gradient( 3 | to right, 4 | red, 5 | orange, 6 | yellow, 7 | green, 8 | blue, 9 | indigo, 10 | violet 11 | ); 12 | } 13 | #room-update-Pomodoro-Room .room__header { 14 | background-image: linear-gradient(to right, red, white); 15 | } 16 | -------------------------------------------------------------------------------- /public/personalizations/hide-fouc.css: -------------------------------------------------------------------------------- 1 | /* See https://stackoverflow.com/a/53364612 */ 2 | html { 3 | visibility: hidden; 4 | opacity: 0; 5 | } 6 | -------------------------------------------------------------------------------- /public/personalizations/icons.css: -------------------------------------------------------------------------------- 1 | .room__join { 2 | /* Google Material Symbols "Video Camera Front", downloaded SVG */ 3 | /* But manually replaced the hex fill with RGB, otherwise it breaks */ 4 | background-image: url("data:image/svg+xml, "); 5 | background-repeat: no-repeat; 6 | background-position-y: center; 7 | background-position-x: right; 8 | padding-right: 1.4em; 9 | background-size: contain; 10 | } 11 | 12 | .room__event-calendar-link { 13 | /* Google Material Symbols "Calendar Month", downloaded SVG */ 14 | /* But manually replaced the hex fill with RGB, otherwise it breaks */ 15 | background-image: url("data:image/svg+xml,"); 16 | background-repeat: no-repeat; 17 | background-position-y: center; 18 | background-position-x: right; 19 | padding-right: 1.4em; 20 | background-size: contain; 21 | } 22 | -------------------------------------------------------------------------------- /public/personalizations/rainbow-gradient-animated.css: -------------------------------------------------------------------------------- 1 | html { 2 | --opacity: 20%; 3 | background: linear-gradient( 4 | to bottom, 5 | /* red */ hsla(0deg 100% 50% / var(--opacity)), 6 | /* orange */ hsla(38.82deg 100% 50% / var(--opacity)), 7 | /* yellow */ hsla(60deg 100% 50% / var(--opacity)), 8 | /* green */ hsla(120deg 100% 25.1% / var(--opacity)), 9 | /* blue */ hsla(240deg 100% 50% / var(--opacity)), 10 | /* indigo */ hsla(274.62deg 100% 25.49% / var(--opacity)), 11 | /* violet */ hsla(300deg 76.06% 72.16% / var(--opacity)), 12 | /* red again */ hsla(0deg 100% 50% / var(--opacity)) 13 | ); 14 | 15 | background-size: 400% 400%; 16 | background-position: 50% 0%; 17 | animation: gradient 15s ease infinite; 18 | height: 100vh; 19 | } 20 | 21 | @keyframes gradient { 22 | 0% { 23 | background-position: 50% 0%; 24 | } 25 | 50% { 26 | background-position: 50% 50%; 27 | } 28 | 100% { 29 | background-position: 50% 0%; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/personalizations/rainbowify-participant-borders.js: -------------------------------------------------------------------------------- 1 | { 2 | const colors = [ 3 | "red", 4 | "orange", 5 | "yellow", 6 | "green", 7 | "blue", 8 | "indigo", 9 | "violet", 10 | ]; 11 | const customize = () => { 12 | document.querySelectorAll(".participants__face").forEach((p) => { 13 | p.style.borderColor = colors[(Math.random() * colors.length) | 0]; 14 | }); 15 | }; 16 | document.body.addEventListener("htmx:afterSwap", customize); 17 | document.body.addEventListener("htmx:wsAfterMessage", customize); 18 | customize(); 19 | } 20 | -------------------------------------------------------------------------------- /public/personalizations/rcverse-base-style.css: -------------------------------------------------------------------------------- 1 | /* Reset / General */ 2 | 3 | body { 4 | margin: 0; 5 | } 6 | 7 | p { 8 | max-width: 60ch; 9 | margin: 0; 10 | } 11 | 12 | button { 13 | cursor: pointer; 14 | } 15 | 16 | dd { 17 | margin-inline-start: 0px; 18 | } 19 | 20 | summary { 21 | cursor: pointer; 22 | } 23 | 24 | main { 25 | padding-inline: 1rem; 26 | } 27 | 28 | h1, 29 | h2, 30 | h3, 31 | h4, 32 | h5, 33 | h6 { 34 | font-weight: normal; 35 | margin: 0; 36 | } 37 | 38 | /* BEM - See https://getbem.com/introduction/ */ 39 | 40 | .participants { 41 | display: flex; 42 | align-items: center; 43 | justify-content: space-between; 44 | gap: 0.4em; 45 | width: 100%; 46 | } 47 | 48 | .participants__faces { 49 | display: inline-flex; 50 | flex-direction: row-reverse; 51 | flex-wrap: wrap; 52 | justify-content: flex-end; 53 | align-items: center; 54 | } 55 | 56 | .participants__face { 57 | position: relative; 58 | aspect-ratio: 1; 59 | border-radius: 99999px; 60 | border: 4px solid forestgreen; 61 | } 62 | 63 | .participants__face:not(:first-child) { 64 | margin-right: -0.8em; 65 | } 66 | 67 | .empty-room-memo { 68 | font-size: 0.8em; 69 | font-style: italic; 70 | } 71 | 72 | .room-list { 73 | /* "An Auto-Filling CSS Grid With Max Columns of a Minimum Size" */ 74 | /* From https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */ 75 | --grid-layout-gap: 0.4em; 76 | --grid-column-count: 3; 77 | --grid-item--min-width: 350px; 78 | 79 | /** 80 | * Calculated values. 81 | */ 82 | --gap-count: calc(var(--grid-column-count) - 1); 83 | --total-gap-width: calc(var(--gap-count) * var(--grid-layout-gap)); 84 | --grid-item--max-width: calc( 85 | (100% - var(--total-gap-width)) / var(--grid-column-count) 86 | ); 87 | 88 | display: grid; 89 | grid-template-columns: repeat( 90 | auto-fill, 91 | minmax(max(var(--grid-item--min-width), var(--grid-item--max-width)), 1fr) 92 | ); 93 | grid-gap: var(--grid-layout-gap); 94 | } 95 | 96 | .room { 97 | margin-bottom: 0.4em; 98 | 99 | display: flex; 100 | flex-direction: column; 101 | justify-content: space-between; 102 | } 103 | 104 | /* TODO: Probably better to find a layout which works for everything */ 105 | #in-the-hub-update .room { 106 | justify-content: flex-start; 107 | } 108 | 109 | .room:hover { 110 | background-color: rgba(0, 0, 0, 0.05); 111 | } 112 | 113 | .room__header { 114 | background: linear-gradient(to right, #eee, #eee 80%, #fff); 115 | padding: 0.4em; 116 | border-top-left-radius: 0.4em; 117 | border-top-right-radius: 0.4em; 118 | display: flex; 119 | flex-direction: row; 120 | justify-content: space-between; 121 | align-items: center; 122 | gap: 0.4em; 123 | } 124 | 125 | .room__header-title { 126 | display: flex; 127 | flex-direction: row; 128 | align-items: center; 129 | gap: 0.6em; 130 | } 131 | 132 | .room__title { 133 | font-size: 1.4em; 134 | } 135 | 136 | .room__title-button { 137 | font-size: 0.8em; 138 | } 139 | 140 | .room__count { 141 | font-size: 0.8em; 142 | color: darkgray; 143 | white-space: nowrap; 144 | min-width: 10ch; 145 | } 146 | 147 | .room__details { 148 | padding: 0.4em; 149 | display: flex; 150 | flex-direction: column; 151 | justify-content: space-between; 152 | align-items: baseline; 153 | gap: 0.4em; 154 | } 155 | 156 | .room__note-updates { 157 | width: 100%; 158 | display: flex; 159 | flex-direction: row; 160 | justify-content: space-between; 161 | align-items: baseline; 162 | gap: 0.4em; 163 | } 164 | 165 | .room__note-update-time { 166 | font-size: 0.6em; 167 | font-style: italic; 168 | } 169 | 170 | .room__note-edit-button { 171 | font-size: 0.8em; 172 | } 173 | 174 | .note-editor, 175 | .note-editor__form-item { 176 | display: flex; 177 | flex-direction: column; 178 | gap: 0.2em; 179 | } 180 | 181 | .note-editor__text-input { 182 | width: 100%; 183 | } 184 | 185 | .personalization__code-preformatted { 186 | white-space: pre-wrap; 187 | } 188 | 189 | /* Utilities */ 190 | 191 | /* Remove from the box model, useful for HTMX utilities */ 192 | .display-contents { 193 | display: contents; 194 | } 195 | -------------------------------------------------------------------------------- /public/personalizations/recurse-com__font-awesome.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */ 5 | /* TODO These fail, probably premium? I forget how font awesome's model works */ 6 | @font-face { 7 | font-family: "FontAwesome"; 8 | src: url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-e61f1c597be34c10068355f71d8951a9a3601d011a23b6dde920e49e1ba7e201.eot"); 9 | src: 10 | url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-e61f1c597be34c10068355f71d8951a9a3601d011a23b6dde920e49e1ba7e201.eot?#iefix") 11 | format("embedded-opentype"), 12 | url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-32595182c018a4d09f6ad3ec4350a7df1a7c38c30b75249c2cb3bd3a41f50325.woff2") 13 | format("woff2"), 14 | url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-d3fb28a8325cc4358f05acfe1c5a93e3a4e257272d0e6bcd3f53c8df784cf808.woff") 15 | format("woff"), 16 | url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-c844950e2145db658a1bb503b7e0b6bc9596b374879f3dd4f953f86b56dd56d9.ttf") 17 | format("truetype"), 18 | url("https://d29xw0ra2h4o4u.cloudfront.net/assets/fontawesome-webfont-1ec41755bc853419140cf248621912bba82485ea51e4490f9dbfc0f4e2cda80a.svg#fontawesomeregular") 19 | format("svg"); 20 | font-weight: normal; 21 | font-style: normal; 22 | } 23 | 24 | .fa, 25 | .timeline-item__bullet, 26 | .skill-tag__undo, 27 | .skill-tag__x, 28 | .selectable-item__asterisk, 29 | .selectable-item__general-chat-icon, 30 | .selectable-item__job-posting-search-icon, 31 | .selectable-item.is-selected:before, 32 | .profile-card__favorite-icon, 33 | .profile-summary__favorite-icon, 34 | .jobs-profile-floatie__nav-button > i, 35 | .job-seeker-miniprofile__work-locations-icon, 36 | .job-seeker-miniprofile__visa-info-icon, 37 | .group-header__destroy-icon, 38 | .group-header__toggle-icon, 39 | .group-header__edit-icon, 40 | .group-header__drag-icon, 41 | .editor-section__disclosure-icon, 42 | .editable-preview__drag-icon, 43 | .bool-status.is-false i.bool-status__icon, 44 | .bool-status.is-true i.bool-status__icon { 45 | display: inline-block; 46 | font: normal normal normal 14px/1 FontAwesome; 47 | font-size: inherit; 48 | text-rendering: auto; 49 | -webkit-font-smoothing: antialiased; 50 | -moz-osx-font-smoothing: grayscale; 51 | } 52 | 53 | .fa-lg { 54 | font-size: 1.33333333em; 55 | line-height: 0.75em; 56 | vertical-align: -15%; 57 | } 58 | 59 | .fa-2x { 60 | font-size: 2em; 61 | } 62 | 63 | .fa-3x { 64 | font-size: 3em; 65 | } 66 | 67 | .fa-4x { 68 | font-size: 4em; 69 | } 70 | 71 | .fa-5x { 72 | font-size: 5em; 73 | } 74 | 75 | .fa-fw { 76 | width: 1.28571429em; 77 | text-align: center; 78 | } 79 | 80 | .fa-ul { 81 | padding-left: 0; 82 | margin-left: 2.14285714em; 83 | list-style-type: none; 84 | } 85 | 86 | .fa-ul > li { 87 | position: relative; 88 | } 89 | 90 | .fa-li { 91 | position: absolute; 92 | left: -2.14285714em; 93 | width: 2.14285714em; 94 | top: 0.14285714em; 95 | text-align: center; 96 | } 97 | 98 | .fa-li.fa-lg { 99 | left: -1.85714286em; 100 | } 101 | 102 | .fa-border { 103 | padding: 0.2em 0.25em 0.15em; 104 | border: solid 0.08em #eeeeee; 105 | border-radius: 0.1em; 106 | } 107 | 108 | .fa-pull-left { 109 | float: left; 110 | } 111 | 112 | .fa-pull-right { 113 | float: right; 114 | } 115 | 116 | .fa.fa-pull-left, 117 | .fa-pull-left.timeline-item__bullet, 118 | .fa-pull-left.skill-tag__undo, 119 | .fa-pull-left.skill-tag__x, 120 | .fa-pull-left.selectable-item__asterisk, 121 | .fa-pull-left.selectable-item__general-chat-icon, 122 | .fa-pull-left.selectable-item__job-posting-search-icon, 123 | .fa-pull-left.selectable-item.is-selected:before, 124 | .fa-pull-left.profile-card__favorite-icon, 125 | .fa-pull-left.profile-summary__favorite-icon, 126 | .jobs-profile-floatie__nav-button > i.fa-pull-left, 127 | .fa-pull-left.job-seeker-miniprofile__work-locations-icon, 128 | .fa-pull-left.job-seeker-miniprofile__visa-info-icon, 129 | .fa-pull-left.group-header__destroy-icon, 130 | .fa-pull-left.group-header__toggle-icon, 131 | .fa-pull-left.group-header__edit-icon, 132 | .fa-pull-left.group-header__drag-icon, 133 | .fa-pull-left.editor-section__disclosure-icon, 134 | .fa-pull-left.editable-preview__drag-icon, 135 | .bool-status.is-false i.fa-pull-left.bool-status__icon, 136 | .bool-status.is-true i.fa-pull-left.bool-status__icon { 137 | margin-right: 0.3em; 138 | } 139 | 140 | .fa.fa-pull-right, 141 | .fa-pull-right.timeline-item__bullet, 142 | .fa-pull-right.skill-tag__undo, 143 | .fa-pull-right.skill-tag__x, 144 | .fa-pull-right.selectable-item__asterisk, 145 | .fa-pull-right.selectable-item__general-chat-icon, 146 | .fa-pull-right.selectable-item__job-posting-search-icon, 147 | .fa-pull-right.selectable-item.is-selected:before, 148 | .fa-pull-right.profile-card__favorite-icon, 149 | .fa-pull-right.profile-summary__favorite-icon, 150 | .jobs-profile-floatie__nav-button > i.fa-pull-right, 151 | .fa-pull-right.job-seeker-miniprofile__work-locations-icon, 152 | .fa-pull-right.job-seeker-miniprofile__visa-info-icon, 153 | .fa-pull-right.group-header__destroy-icon, 154 | .fa-pull-right.group-header__toggle-icon, 155 | .fa-pull-right.group-header__edit-icon, 156 | .fa-pull-right.group-header__drag-icon, 157 | .fa-pull-right.editor-section__disclosure-icon, 158 | .fa-pull-right.editable-preview__drag-icon, 159 | .bool-status.is-false i.fa-pull-right.bool-status__icon, 160 | .bool-status.is-true i.fa-pull-right.bool-status__icon { 161 | margin-left: 0.3em; 162 | } 163 | 164 | .pull-right { 165 | float: right; 166 | } 167 | 168 | .pull-left { 169 | float: left; 170 | } 171 | 172 | .fa.pull-left, 173 | .pull-left.timeline-item__bullet, 174 | .pull-left.skill-tag__undo, 175 | .pull-left.skill-tag__x, 176 | .pull-left.selectable-item__asterisk, 177 | .pull-left.selectable-item__general-chat-icon, 178 | .pull-left.selectable-item__job-posting-search-icon, 179 | .pull-left.selectable-item.is-selected:before, 180 | .pull-left.profile-card__favorite-icon, 181 | .pull-left.profile-summary__favorite-icon, 182 | .jobs-profile-floatie__nav-button > i.pull-left, 183 | .pull-left.job-seeker-miniprofile__work-locations-icon, 184 | .pull-left.job-seeker-miniprofile__visa-info-icon, 185 | .pull-left.group-header__destroy-icon, 186 | .pull-left.group-header__toggle-icon, 187 | .pull-left.group-header__edit-icon, 188 | .pull-left.group-header__drag-icon, 189 | .pull-left.editor-section__disclosure-icon, 190 | .pull-left.editable-preview__drag-icon, 191 | .bool-status.is-false i.pull-left.bool-status__icon, 192 | .bool-status.is-true i.pull-left.bool-status__icon { 193 | margin-right: 0.3em; 194 | } 195 | 196 | .fa.pull-right, 197 | .pull-right.timeline-item__bullet, 198 | .pull-right.skill-tag__undo, 199 | .pull-right.skill-tag__x, 200 | .pull-right.selectable-item__asterisk, 201 | .pull-right.selectable-item__general-chat-icon, 202 | .pull-right.selectable-item__job-posting-search-icon, 203 | .pull-right.selectable-item.is-selected:before, 204 | .pull-right.profile-card__favorite-icon, 205 | .pull-right.profile-summary__favorite-icon, 206 | .jobs-profile-floatie__nav-button > i.pull-right, 207 | .pull-right.job-seeker-miniprofile__work-locations-icon, 208 | .pull-right.job-seeker-miniprofile__visa-info-icon, 209 | .pull-right.group-header__destroy-icon, 210 | .pull-right.group-header__toggle-icon, 211 | .pull-right.group-header__edit-icon, 212 | .pull-right.group-header__drag-icon, 213 | .pull-right.editor-section__disclosure-icon, 214 | .pull-right.editable-preview__drag-icon, 215 | .bool-status.is-false i.pull-right.bool-status__icon, 216 | .bool-status.is-true i.pull-right.bool-status__icon { 217 | margin-left: 0.3em; 218 | } 219 | 220 | .fa-spin { 221 | -webkit-animation: fa-spin 2s infinite linear; 222 | animation: fa-spin 2s infinite linear; 223 | } 224 | 225 | .fa-pulse { 226 | -webkit-animation: fa-spin 1s infinite steps(8); 227 | animation: fa-spin 1s infinite steps(8); 228 | } 229 | 230 | @-webkit-keyframes fa-spin { 231 | 0% { 232 | -webkit-transform: rotate(0deg); 233 | transform: rotate(0deg); 234 | } 235 | 236 | 100% { 237 | -webkit-transform: rotate(359deg); 238 | transform: rotate(359deg); 239 | } 240 | } 241 | 242 | @keyframes fa-spin { 243 | 0% { 244 | -webkit-transform: rotate(0deg); 245 | transform: rotate(0deg); 246 | } 247 | 248 | 100% { 249 | -webkit-transform: rotate(359deg); 250 | transform: rotate(359deg); 251 | } 252 | } 253 | 254 | .fa-rotate-90 { 255 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; 256 | -webkit-transform: rotate(90deg); 257 | -ms-transform: rotate(90deg); 258 | transform: rotate(90deg); 259 | } 260 | 261 | .fa-rotate-180 { 262 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; 263 | -webkit-transform: rotate(180deg); 264 | -ms-transform: rotate(180deg); 265 | transform: rotate(180deg); 266 | } 267 | 268 | .fa-rotate-270 { 269 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; 270 | -webkit-transform: rotate(270deg); 271 | -ms-transform: rotate(270deg); 272 | transform: rotate(270deg); 273 | } 274 | 275 | .fa-flip-horizontal, 276 | .selectable-item__job-posting-search-icon { 277 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; 278 | -webkit-transform: scale(-1, 1); 279 | -ms-transform: scale(-1, 1); 280 | transform: scale(-1, 1); 281 | } 282 | 283 | .fa-flip-vertical { 284 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; 285 | -webkit-transform: scale(1, -1); 286 | -ms-transform: scale(1, -1); 287 | transform: scale(1, -1); 288 | } 289 | 290 | :root .fa-rotate-90, 291 | :root .fa-rotate-180, 292 | :root .fa-rotate-270, 293 | :root .fa-flip-horizontal, 294 | :root .selectable-item__job-posting-search-icon, 295 | :root .fa-flip-vertical { 296 | -webkit-filter: none; 297 | filter: none; 298 | } 299 | 300 | .fa-stack { 301 | position: relative; 302 | display: inline-block; 303 | width: 2em; 304 | height: 2em; 305 | line-height: 2em; 306 | vertical-align: middle; 307 | } 308 | 309 | .fa-stack-1x, 310 | .fa-stack-2x { 311 | position: absolute; 312 | left: 0; 313 | width: 100%; 314 | text-align: center; 315 | } 316 | 317 | .fa-stack-1x { 318 | line-height: inherit; 319 | } 320 | 321 | .fa-stack-2x { 322 | font-size: 2em; 323 | } 324 | 325 | .fa-inverse { 326 | color: #ffffff; 327 | } 328 | 329 | .fa-glass:before { 330 | content: "\f000"; 331 | } 332 | 333 | .fa-music:before { 334 | content: "\f001"; 335 | } 336 | 337 | .fa-search:before, 338 | .selectable-item__job-posting-search-icon:before { 339 | content: "\f002"; 340 | } 341 | 342 | .fa-envelope-o:before { 343 | content: "\f003"; 344 | } 345 | 346 | .fa-heart:before, 347 | .profile-card__favorite-icon:before, 348 | .profile-summary__favorite-icon:before { 349 | content: "\f004"; 350 | } 351 | 352 | .fa-star:before { 353 | content: "\f005"; 354 | } 355 | 356 | .fa-star-o:before { 357 | content: "\f006"; 358 | } 359 | 360 | .fa-user:before { 361 | content: "\f007"; 362 | } 363 | 364 | .fa-film:before { 365 | content: "\f008"; 366 | } 367 | 368 | .fa-th-large:before { 369 | content: "\f009"; 370 | } 371 | 372 | .fa-th:before { 373 | content: "\f00a"; 374 | } 375 | 376 | .fa-th-list:before { 377 | content: "\f00b"; 378 | } 379 | 380 | .fa-check:before, 381 | .bool-status.is-true i.bool-status__icon:before { 382 | content: "\f00c"; 383 | } 384 | 385 | .fa-remove:before, 386 | .fa-close:before, 387 | .fa-times:before, 388 | .skill-tag__x:before, 389 | .bool-status.is-false i.bool-status__icon:before { 390 | content: "\f00d"; 391 | } 392 | 393 | .fa-search-plus:before { 394 | content: "\f00e"; 395 | } 396 | 397 | .fa-search-minus:before { 398 | content: "\f010"; 399 | } 400 | 401 | .fa-power-off:before { 402 | content: "\f011"; 403 | } 404 | 405 | .fa-signal:before { 406 | content: "\f012"; 407 | } 408 | 409 | .fa-gear:before, 410 | .fa-cog:before { 411 | content: "\f013"; 412 | } 413 | 414 | .fa-trash-o:before, 415 | .group-header__destroy-icon:before { 416 | content: "\f014"; 417 | } 418 | 419 | .fa-home:before { 420 | content: "\f015"; 421 | } 422 | 423 | .fa-file-o:before { 424 | content: "\f016"; 425 | } 426 | 427 | .fa-clock-o:before { 428 | content: "\f017"; 429 | } 430 | 431 | .fa-road:before { 432 | content: "\f018"; 433 | } 434 | 435 | .fa-download:before { 436 | content: "\f019"; 437 | } 438 | 439 | .fa-arrow-circle-o-down:before { 440 | content: "\f01a"; 441 | } 442 | 443 | .fa-arrow-circle-o-up:before { 444 | content: "\f01b"; 445 | } 446 | 447 | .fa-inbox:before { 448 | content: "\f01c"; 449 | } 450 | 451 | .fa-play-circle-o:before { 452 | content: "\f01d"; 453 | } 454 | 455 | .fa-rotate-right:before, 456 | .fa-repeat:before { 457 | content: "\f01e"; 458 | } 459 | 460 | .fa-refresh:before { 461 | content: "\f021"; 462 | } 463 | 464 | .fa-list-alt:before { 465 | content: "\f022"; 466 | } 467 | 468 | .fa-lock:before { 469 | content: "\f023"; 470 | } 471 | 472 | .fa-flag:before, 473 | .job-seeker-miniprofile__visa-info-icon:before { 474 | content: "\f024"; 475 | } 476 | 477 | .fa-headphones:before { 478 | content: "\f025"; 479 | } 480 | 481 | .fa-volume-off:before { 482 | content: "\f026"; 483 | } 484 | 485 | .fa-volume-down:before { 486 | content: "\f027"; 487 | } 488 | 489 | .fa-volume-up:before { 490 | content: "\f028"; 491 | } 492 | 493 | .fa-qrcode:before { 494 | content: "\f029"; 495 | } 496 | 497 | .fa-barcode:before { 498 | content: "\f02a"; 499 | } 500 | 501 | .fa-tag:before { 502 | content: "\f02b"; 503 | } 504 | 505 | .fa-tags:before { 506 | content: "\f02c"; 507 | } 508 | 509 | .fa-book:before { 510 | content: "\f02d"; 511 | } 512 | 513 | .fa-bookmark:before { 514 | content: "\f02e"; 515 | } 516 | 517 | .fa-print:before { 518 | content: "\f02f"; 519 | } 520 | 521 | .fa-camera:before { 522 | content: "\f030"; 523 | } 524 | 525 | .fa-font:before { 526 | content: "\f031"; 527 | } 528 | 529 | .fa-bold:before { 530 | content: "\f032"; 531 | } 532 | 533 | .fa-italic:before { 534 | content: "\f033"; 535 | } 536 | 537 | .fa-text-height:before { 538 | content: "\f034"; 539 | } 540 | 541 | .fa-text-width:before { 542 | content: "\f035"; 543 | } 544 | 545 | .fa-align-left:before { 546 | content: "\f036"; 547 | } 548 | 549 | .fa-align-center:before { 550 | content: "\f037"; 551 | } 552 | 553 | .fa-align-right:before { 554 | content: "\f038"; 555 | } 556 | 557 | .fa-align-justify:before { 558 | content: "\f039"; 559 | } 560 | 561 | .fa-list:before { 562 | content: "\f03a"; 563 | } 564 | 565 | .fa-dedent:before, 566 | .fa-outdent:before { 567 | content: "\f03b"; 568 | } 569 | 570 | .fa-indent:before { 571 | content: "\f03c"; 572 | } 573 | 574 | .fa-video-camera:before { 575 | content: "\f03d"; 576 | } 577 | 578 | .fa-photo:before, 579 | .fa-image:before, 580 | .fa-picture-o:before { 581 | content: "\f03e"; 582 | } 583 | 584 | .fa-pencil:before, 585 | .group-header__edit-icon:before { 586 | content: "\f040"; 587 | } 588 | 589 | .fa-map-marker:before, 590 | .job-seeker-miniprofile__work-locations-icon:before { 591 | content: "\f041"; 592 | } 593 | 594 | .fa-adjust:before { 595 | content: "\f042"; 596 | } 597 | 598 | .fa-tint:before { 599 | content: "\f043"; 600 | } 601 | 602 | .fa-edit:before, 603 | .fa-pencil-square-o:before { 604 | content: "\f044"; 605 | } 606 | 607 | .fa-share-square-o:before { 608 | content: "\f045"; 609 | } 610 | 611 | .fa-check-square-o:before { 612 | content: "\f046"; 613 | } 614 | 615 | .fa-arrows:before { 616 | content: "\f047"; 617 | } 618 | 619 | .fa-step-backward:before { 620 | content: "\f048"; 621 | } 622 | 623 | .fa-fast-backward:before { 624 | content: "\f049"; 625 | } 626 | 627 | .fa-backward:before { 628 | content: "\f04a"; 629 | } 630 | 631 | .fa-play:before { 632 | content: "\f04b"; 633 | } 634 | 635 | .fa-pause:before { 636 | content: "\f04c"; 637 | } 638 | 639 | .fa-stop:before { 640 | content: "\f04d"; 641 | } 642 | 643 | .fa-forward:before { 644 | content: "\f04e"; 645 | } 646 | 647 | .fa-fast-forward:before { 648 | content: "\f050"; 649 | } 650 | 651 | .fa-step-forward:before { 652 | content: "\f051"; 653 | } 654 | 655 | .fa-eject:before { 656 | content: "\f052"; 657 | } 658 | 659 | .fa-chevron-left:before { 660 | content: "\f053"; 661 | } 662 | 663 | .fa-chevron-right:before, 664 | .selectable-item.is-selected:before, 665 | .group-header__toggle-icon.toggled-right:before, 666 | .editor-section__disclosure-icon:before { 667 | content: "\f054"; 668 | } 669 | 670 | .fa-plus-circle:before { 671 | content: "\f055"; 672 | } 673 | 674 | .fa-minus-circle:before { 675 | content: "\f056"; 676 | } 677 | 678 | .fa-times-circle:before { 679 | content: "\f057"; 680 | } 681 | 682 | .fa-check-circle:before { 683 | content: "\f058"; 684 | } 685 | 686 | .fa-question-circle:before { 687 | content: "\f059"; 688 | } 689 | 690 | .fa-info-circle:before { 691 | content: "\f05a"; 692 | } 693 | 694 | .fa-crosshairs:before { 695 | content: "\f05b"; 696 | } 697 | 698 | .fa-times-circle-o:before { 699 | content: "\f05c"; 700 | } 701 | 702 | .fa-check-circle-o:before { 703 | content: "\f05d"; 704 | } 705 | 706 | .fa-ban:before { 707 | content: "\f05e"; 708 | } 709 | 710 | .fa-arrow-left:before { 711 | content: "\f060"; 712 | } 713 | 714 | .fa-arrow-right:before { 715 | content: "\f061"; 716 | } 717 | 718 | .fa-arrow-up:before { 719 | content: "\f062"; 720 | } 721 | 722 | .fa-arrow-down:before { 723 | content: "\f063"; 724 | } 725 | 726 | .fa-mail-forward:before, 727 | .fa-share:before { 728 | content: "\f064"; 729 | } 730 | 731 | .fa-expand:before { 732 | content: "\f065"; 733 | } 734 | 735 | .fa-compress:before { 736 | content: "\f066"; 737 | } 738 | 739 | .fa-plus:before { 740 | content: "\f067"; 741 | } 742 | 743 | .fa-minus:before { 744 | content: "\f068"; 745 | } 746 | 747 | .fa-asterisk:before, 748 | .selectable-item__asterisk:before { 749 | content: "\f069"; 750 | } 751 | 752 | .fa-exclamation-circle:before { 753 | content: "\f06a"; 754 | } 755 | 756 | .fa-gift:before { 757 | content: "\f06b"; 758 | } 759 | 760 | .fa-leaf:before { 761 | content: "\f06c"; 762 | } 763 | 764 | .fa-fire:before { 765 | content: "\f06d"; 766 | } 767 | 768 | .fa-eye:before { 769 | content: "\f06e"; 770 | } 771 | 772 | .fa-eye-slash:before { 773 | content: "\f070"; 774 | } 775 | 776 | .fa-warning:before, 777 | .fa-exclamation-triangle:before { 778 | content: "\f071"; 779 | } 780 | 781 | .fa-plane:before { 782 | content: "\f072"; 783 | } 784 | 785 | .fa-calendar:before { 786 | content: "\f073"; 787 | } 788 | 789 | .fa-random:before { 790 | content: "\f074"; 791 | } 792 | 793 | .fa-comment:before, 794 | .selectable-item__general-chat-icon:before { 795 | content: "\f075"; 796 | } 797 | 798 | .fa-magnet:before { 799 | content: "\f076"; 800 | } 801 | 802 | .fa-chevron-up:before { 803 | content: "\f077"; 804 | } 805 | 806 | .fa-chevron-down:before, 807 | .group-header__toggle-icon.toggled-down:before { 808 | content: "\f078"; 809 | } 810 | 811 | .fa-retweet:before { 812 | content: "\f079"; 813 | } 814 | 815 | .fa-shopping-cart:before { 816 | content: "\f07a"; 817 | } 818 | 819 | .fa-folder:before { 820 | content: "\f07b"; 821 | } 822 | 823 | .fa-folder-open:before { 824 | content: "\f07c"; 825 | } 826 | 827 | .fa-arrows-v:before { 828 | content: "\f07d"; 829 | } 830 | 831 | .fa-arrows-h:before { 832 | content: "\f07e"; 833 | } 834 | 835 | .fa-bar-chart-o:before, 836 | .fa-bar-chart:before { 837 | content: "\f080"; 838 | } 839 | 840 | .fa-twitter-square:before { 841 | content: "\f081"; 842 | } 843 | 844 | .fa-facebook-square:before { 845 | content: "\f082"; 846 | } 847 | 848 | .fa-camera-retro:before { 849 | content: "\f083"; 850 | } 851 | 852 | .fa-key:before { 853 | content: "\f084"; 854 | } 855 | 856 | .fa-gears:before, 857 | .fa-cogs:before { 858 | content: "\f085"; 859 | } 860 | 861 | .fa-comments:before { 862 | content: "\f086"; 863 | } 864 | 865 | .fa-thumbs-o-up:before { 866 | content: "\f087"; 867 | } 868 | 869 | .fa-thumbs-o-down:before { 870 | content: "\f088"; 871 | } 872 | 873 | .fa-star-half:before { 874 | content: "\f089"; 875 | } 876 | 877 | .fa-heart-o:before { 878 | content: "\f08a"; 879 | } 880 | 881 | .fa-sign-out:before { 882 | content: "\f08b"; 883 | } 884 | 885 | .fa-linkedin-square:before { 886 | content: "\f08c"; 887 | } 888 | 889 | .fa-thumb-tack:before { 890 | content: "\f08d"; 891 | } 892 | 893 | .fa-external-link:before { 894 | content: "\f08e"; 895 | } 896 | 897 | .fa-sign-in:before { 898 | content: "\f090"; 899 | } 900 | 901 | .fa-trophy:before { 902 | content: "\f091"; 903 | } 904 | 905 | .fa-github-square:before { 906 | content: "\f092"; 907 | } 908 | 909 | .fa-upload:before { 910 | content: "\f093"; 911 | } 912 | 913 | .fa-lemon-o:before { 914 | content: "\f094"; 915 | } 916 | 917 | .fa-phone:before { 918 | content: "\f095"; 919 | } 920 | 921 | .fa-square-o:before { 922 | content: "\f096"; 923 | } 924 | 925 | .fa-bookmark-o:before { 926 | content: "\f097"; 927 | } 928 | 929 | .fa-phone-square:before { 930 | content: "\f098"; 931 | } 932 | 933 | .fa-twitter:before { 934 | content: "\f099"; 935 | } 936 | 937 | .fa-facebook-f:before, 938 | .fa-facebook:before { 939 | content: "\f09a"; 940 | } 941 | 942 | .fa-github:before { 943 | content: "\f09b"; 944 | } 945 | 946 | .fa-unlock:before { 947 | content: "\f09c"; 948 | } 949 | 950 | .fa-credit-card:before { 951 | content: "\f09d"; 952 | } 953 | 954 | .fa-feed:before, 955 | .fa-rss:before { 956 | content: "\f09e"; 957 | } 958 | 959 | .fa-hdd-o:before { 960 | content: "\f0a0"; 961 | } 962 | 963 | .fa-bullhorn:before { 964 | content: "\f0a1"; 965 | } 966 | 967 | .fa-bell:before { 968 | content: "\f0f3"; 969 | } 970 | 971 | .fa-certificate:before { 972 | content: "\f0a3"; 973 | } 974 | 975 | .fa-hand-o-right:before { 976 | content: "\f0a4"; 977 | } 978 | 979 | .fa-hand-o-left:before { 980 | content: "\f0a5"; 981 | } 982 | 983 | .fa-hand-o-up:before { 984 | content: "\f0a6"; 985 | } 986 | 987 | .fa-hand-o-down:before { 988 | content: "\f0a7"; 989 | } 990 | 991 | .fa-arrow-circle-left:before { 992 | content: "\f0a8"; 993 | } 994 | 995 | .fa-arrow-circle-right:before { 996 | content: "\f0a9"; 997 | } 998 | 999 | .fa-arrow-circle-up:before { 1000 | content: "\f0aa"; 1001 | } 1002 | 1003 | .fa-arrow-circle-down:before { 1004 | content: "\f0ab"; 1005 | } 1006 | 1007 | .fa-globe:before { 1008 | content: "\f0ac"; 1009 | } 1010 | 1011 | .fa-wrench:before { 1012 | content: "\f0ad"; 1013 | } 1014 | 1015 | .fa-tasks:before { 1016 | content: "\f0ae"; 1017 | } 1018 | 1019 | .fa-filter:before { 1020 | content: "\f0b0"; 1021 | } 1022 | 1023 | .fa-briefcase:before { 1024 | content: "\f0b1"; 1025 | } 1026 | 1027 | .fa-arrows-alt:before { 1028 | content: "\f0b2"; 1029 | } 1030 | 1031 | .fa-group:before, 1032 | .fa-users:before { 1033 | content: "\f0c0"; 1034 | } 1035 | 1036 | .fa-chain:before, 1037 | .fa-link:before { 1038 | content: "\f0c1"; 1039 | } 1040 | 1041 | .fa-cloud:before { 1042 | content: "\f0c2"; 1043 | } 1044 | 1045 | .fa-flask:before { 1046 | content: "\f0c3"; 1047 | } 1048 | 1049 | .fa-cut:before, 1050 | .fa-scissors:before { 1051 | content: "\f0c4"; 1052 | } 1053 | 1054 | .fa-copy:before, 1055 | .fa-files-o:before { 1056 | content: "\f0c5"; 1057 | } 1058 | 1059 | .fa-paperclip:before { 1060 | content: "\f0c6"; 1061 | } 1062 | 1063 | .fa-save:before, 1064 | .fa-floppy-o:before { 1065 | content: "\f0c7"; 1066 | } 1067 | 1068 | .fa-square:before { 1069 | content: "\f0c8"; 1070 | } 1071 | 1072 | .fa-navicon:before, 1073 | .fa-reorder:before, 1074 | .fa-bars:before, 1075 | .jobs-profile-floatie__nav-button > i:before { 1076 | content: "\f0c9"; 1077 | } 1078 | 1079 | .fa-list-ul:before { 1080 | content: "\f0ca"; 1081 | } 1082 | 1083 | .fa-list-ol:before { 1084 | content: "\f0cb"; 1085 | } 1086 | 1087 | .fa-strikethrough:before { 1088 | content: "\f0cc"; 1089 | } 1090 | 1091 | .fa-underline:before { 1092 | content: "\f0cd"; 1093 | } 1094 | 1095 | .fa-table:before { 1096 | content: "\f0ce"; 1097 | } 1098 | 1099 | .fa-magic:before { 1100 | content: "\f0d0"; 1101 | } 1102 | 1103 | .fa-truck:before { 1104 | content: "\f0d1"; 1105 | } 1106 | 1107 | .fa-pinterest:before { 1108 | content: "\f0d2"; 1109 | } 1110 | 1111 | .fa-pinterest-square:before { 1112 | content: "\f0d3"; 1113 | } 1114 | 1115 | .fa-google-plus-square:before { 1116 | content: "\f0d4"; 1117 | } 1118 | 1119 | .fa-google-plus:before { 1120 | content: "\f0d5"; 1121 | } 1122 | 1123 | .fa-money:before { 1124 | content: "\f0d6"; 1125 | } 1126 | 1127 | .fa-caret-down:before { 1128 | content: "\f0d7"; 1129 | } 1130 | 1131 | .fa-caret-up:before { 1132 | content: "\f0d8"; 1133 | } 1134 | 1135 | .fa-caret-left:before { 1136 | content: "\f0d9"; 1137 | } 1138 | 1139 | .fa-caret-right:before { 1140 | content: "\f0da"; 1141 | } 1142 | 1143 | .fa-columns:before { 1144 | content: "\f0db"; 1145 | } 1146 | 1147 | .fa-unsorted:before, 1148 | .fa-sort:before { 1149 | content: "\f0dc"; 1150 | } 1151 | 1152 | .fa-sort-down:before, 1153 | .fa-sort-desc:before { 1154 | content: "\f0dd"; 1155 | } 1156 | 1157 | .fa-sort-up:before, 1158 | .fa-sort-asc:before { 1159 | content: "\f0de"; 1160 | } 1161 | 1162 | .fa-envelope:before { 1163 | content: "\f0e0"; 1164 | } 1165 | 1166 | .fa-linkedin:before { 1167 | content: "\f0e1"; 1168 | } 1169 | 1170 | .fa-rotate-left:before, 1171 | .fa-undo:before, 1172 | .skill-tag__undo:before { 1173 | content: "\f0e2"; 1174 | } 1175 | 1176 | .fa-legal:before, 1177 | .fa-gavel:before { 1178 | content: "\f0e3"; 1179 | } 1180 | 1181 | .fa-dashboard:before, 1182 | .fa-tachometer:before { 1183 | content: "\f0e4"; 1184 | } 1185 | 1186 | .fa-comment-o:before { 1187 | content: "\f0e5"; 1188 | } 1189 | 1190 | .fa-comments-o:before { 1191 | content: "\f0e6"; 1192 | } 1193 | 1194 | .fa-flash:before, 1195 | .fa-bolt:before { 1196 | content: "\f0e7"; 1197 | } 1198 | 1199 | .fa-sitemap:before { 1200 | content: "\f0e8"; 1201 | } 1202 | 1203 | .fa-umbrella:before { 1204 | content: "\f0e9"; 1205 | } 1206 | 1207 | .fa-paste:before, 1208 | .fa-clipboard:before { 1209 | content: "\f0ea"; 1210 | } 1211 | 1212 | .fa-lightbulb-o:before { 1213 | content: "\f0eb"; 1214 | } 1215 | 1216 | .fa-exchange:before { 1217 | content: "\f0ec"; 1218 | } 1219 | 1220 | .fa-cloud-download:before { 1221 | content: "\f0ed"; 1222 | } 1223 | 1224 | .fa-cloud-upload:before { 1225 | content: "\f0ee"; 1226 | } 1227 | 1228 | .fa-user-md:before { 1229 | content: "\f0f0"; 1230 | } 1231 | 1232 | .fa-stethoscope:before { 1233 | content: "\f0f1"; 1234 | } 1235 | 1236 | .fa-suitcase:before { 1237 | content: "\f0f2"; 1238 | } 1239 | 1240 | .fa-bell-o:before { 1241 | content: "\f0a2"; 1242 | } 1243 | 1244 | .fa-coffee:before { 1245 | content: "\f0f4"; 1246 | } 1247 | 1248 | .fa-cutlery:before { 1249 | content: "\f0f5"; 1250 | } 1251 | 1252 | .fa-file-text-o:before { 1253 | content: "\f0f6"; 1254 | } 1255 | 1256 | .fa-building-o:before { 1257 | content: "\f0f7"; 1258 | } 1259 | 1260 | .fa-hospital-o:before { 1261 | content: "\f0f8"; 1262 | } 1263 | 1264 | .fa-ambulance:before { 1265 | content: "\f0f9"; 1266 | } 1267 | 1268 | .fa-medkit:before { 1269 | content: "\f0fa"; 1270 | } 1271 | 1272 | .fa-fighter-jet:before { 1273 | content: "\f0fb"; 1274 | } 1275 | 1276 | .fa-beer:before { 1277 | content: "\f0fc"; 1278 | } 1279 | 1280 | .fa-h-square:before { 1281 | content: "\f0fd"; 1282 | } 1283 | 1284 | .fa-plus-square:before { 1285 | content: "\f0fe"; 1286 | } 1287 | 1288 | .fa-angle-double-left:before { 1289 | content: "\f100"; 1290 | } 1291 | 1292 | .fa-angle-double-right:before { 1293 | content: "\f101"; 1294 | } 1295 | 1296 | .fa-angle-double-up:before { 1297 | content: "\f102"; 1298 | } 1299 | 1300 | .fa-angle-double-down:before { 1301 | content: "\f103"; 1302 | } 1303 | 1304 | .fa-angle-left:before { 1305 | content: "\f104"; 1306 | } 1307 | 1308 | .fa-angle-right:before { 1309 | content: "\f105"; 1310 | } 1311 | 1312 | .fa-angle-up:before { 1313 | content: "\f106"; 1314 | } 1315 | 1316 | .fa-angle-down:before { 1317 | content: "\f107"; 1318 | } 1319 | 1320 | .fa-desktop:before { 1321 | content: "\f108"; 1322 | } 1323 | 1324 | .fa-laptop:before { 1325 | content: "\f109"; 1326 | } 1327 | 1328 | .fa-tablet:before { 1329 | content: "\f10a"; 1330 | } 1331 | 1332 | .fa-mobile-phone:before, 1333 | .fa-mobile:before { 1334 | content: "\f10b"; 1335 | } 1336 | 1337 | .fa-circle-o:before { 1338 | content: "\f10c"; 1339 | } 1340 | 1341 | .fa-quote-left:before { 1342 | content: "\f10d"; 1343 | } 1344 | 1345 | .fa-quote-right:before { 1346 | content: "\f10e"; 1347 | } 1348 | 1349 | .fa-spinner:before { 1350 | content: "\f110"; 1351 | } 1352 | 1353 | .fa-circle:before, 1354 | .timeline-item__bullet:before { 1355 | content: "\f111"; 1356 | } 1357 | 1358 | .fa-mail-reply:before, 1359 | .fa-reply:before { 1360 | content: "\f112"; 1361 | } 1362 | 1363 | .fa-github-alt:before { 1364 | content: "\f113"; 1365 | } 1366 | 1367 | .fa-folder-o:before { 1368 | content: "\f114"; 1369 | } 1370 | 1371 | .fa-folder-open-o:before { 1372 | content: "\f115"; 1373 | } 1374 | 1375 | .fa-smile-o:before { 1376 | content: "\f118"; 1377 | } 1378 | 1379 | .fa-frown-o:before { 1380 | content: "\f119"; 1381 | } 1382 | 1383 | .fa-meh-o:before { 1384 | content: "\f11a"; 1385 | } 1386 | 1387 | .fa-gamepad:before { 1388 | content: "\f11b"; 1389 | } 1390 | 1391 | .fa-keyboard-o:before { 1392 | content: "\f11c"; 1393 | } 1394 | 1395 | .fa-flag-o:before { 1396 | content: "\f11d"; 1397 | } 1398 | 1399 | .fa-flag-checkered:before { 1400 | content: "\f11e"; 1401 | } 1402 | 1403 | .fa-terminal:before { 1404 | content: "\f120"; 1405 | } 1406 | 1407 | .fa-code:before { 1408 | content: "\f121"; 1409 | } 1410 | 1411 | .fa-mail-reply-all:before, 1412 | .fa-reply-all:before { 1413 | content: "\f122"; 1414 | } 1415 | 1416 | .fa-star-half-empty:before, 1417 | .fa-star-half-full:before, 1418 | .fa-star-half-o:before { 1419 | content: "\f123"; 1420 | } 1421 | 1422 | .fa-location-arrow:before { 1423 | content: "\f124"; 1424 | } 1425 | 1426 | .fa-crop:before { 1427 | content: "\f125"; 1428 | } 1429 | 1430 | .fa-code-fork:before { 1431 | content: "\f126"; 1432 | } 1433 | 1434 | .fa-unlink:before, 1435 | .fa-chain-broken:before { 1436 | content: "\f127"; 1437 | } 1438 | 1439 | .fa-question:before { 1440 | content: "\f128"; 1441 | } 1442 | 1443 | .fa-info:before { 1444 | content: "\f129"; 1445 | } 1446 | 1447 | .fa-exclamation:before { 1448 | content: "\f12a"; 1449 | } 1450 | 1451 | .fa-superscript:before { 1452 | content: "\f12b"; 1453 | } 1454 | 1455 | .fa-subscript:before { 1456 | content: "\f12c"; 1457 | } 1458 | 1459 | .fa-eraser:before { 1460 | content: "\f12d"; 1461 | } 1462 | 1463 | .fa-puzzle-piece:before { 1464 | content: "\f12e"; 1465 | } 1466 | 1467 | .fa-microphone:before { 1468 | content: "\f130"; 1469 | } 1470 | 1471 | .fa-microphone-slash:before { 1472 | content: "\f131"; 1473 | } 1474 | 1475 | .fa-shield:before { 1476 | content: "\f132"; 1477 | } 1478 | 1479 | .fa-calendar-o:before { 1480 | content: "\f133"; 1481 | } 1482 | 1483 | .fa-fire-extinguisher:before { 1484 | content: "\f134"; 1485 | } 1486 | 1487 | .fa-rocket:before { 1488 | content: "\f135"; 1489 | } 1490 | 1491 | .fa-maxcdn:before { 1492 | content: "\f136"; 1493 | } 1494 | 1495 | .fa-chevron-circle-left:before { 1496 | content: "\f137"; 1497 | } 1498 | 1499 | .fa-chevron-circle-right:before { 1500 | content: "\f138"; 1501 | } 1502 | 1503 | .fa-chevron-circle-up:before { 1504 | content: "\f139"; 1505 | } 1506 | 1507 | .fa-chevron-circle-down:before { 1508 | content: "\f13a"; 1509 | } 1510 | 1511 | .fa-html5:before { 1512 | content: "\f13b"; 1513 | } 1514 | 1515 | .fa-css3:before { 1516 | content: "\f13c"; 1517 | } 1518 | 1519 | .fa-anchor:before { 1520 | content: "\f13d"; 1521 | } 1522 | 1523 | .fa-unlock-alt:before { 1524 | content: "\f13e"; 1525 | } 1526 | 1527 | .fa-bullseye:before { 1528 | content: "\f140"; 1529 | } 1530 | 1531 | .fa-ellipsis-h:before { 1532 | content: "\f141"; 1533 | } 1534 | 1535 | .fa-ellipsis-v:before, 1536 | .group-header__drag-icon:before, 1537 | .editable-preview__drag-icon:before { 1538 | content: "\f142"; 1539 | } 1540 | 1541 | .fa-rss-square:before { 1542 | content: "\f143"; 1543 | } 1544 | 1545 | .fa-play-circle:before { 1546 | content: "\f144"; 1547 | } 1548 | 1549 | .fa-ticket:before { 1550 | content: "\f145"; 1551 | } 1552 | 1553 | .fa-minus-square:before { 1554 | content: "\f146"; 1555 | } 1556 | 1557 | .fa-minus-square-o:before { 1558 | content: "\f147"; 1559 | } 1560 | 1561 | .fa-level-up:before { 1562 | content: "\f148"; 1563 | } 1564 | 1565 | .fa-level-down:before { 1566 | content: "\f149"; 1567 | } 1568 | 1569 | .fa-check-square:before { 1570 | content: "\f14a"; 1571 | } 1572 | 1573 | .fa-pencil-square:before { 1574 | content: "\f14b"; 1575 | } 1576 | 1577 | .fa-external-link-square:before { 1578 | content: "\f14c"; 1579 | } 1580 | 1581 | .fa-share-square:before { 1582 | content: "\f14d"; 1583 | } 1584 | 1585 | .fa-compass:before { 1586 | content: "\f14e"; 1587 | } 1588 | 1589 | .fa-toggle-down:before, 1590 | .fa-caret-square-o-down:before { 1591 | content: "\f150"; 1592 | } 1593 | 1594 | .fa-toggle-up:before, 1595 | .fa-caret-square-o-up:before { 1596 | content: "\f151"; 1597 | } 1598 | 1599 | .fa-toggle-right:before, 1600 | .fa-caret-square-o-right:before { 1601 | content: "\f152"; 1602 | } 1603 | 1604 | .fa-euro:before, 1605 | .fa-eur:before { 1606 | content: "\f153"; 1607 | } 1608 | 1609 | .fa-gbp:before { 1610 | content: "\f154"; 1611 | } 1612 | 1613 | .fa-dollar:before, 1614 | .fa-usd:before { 1615 | content: "\f155"; 1616 | } 1617 | 1618 | .fa-rupee:before, 1619 | .fa-inr:before { 1620 | content: "\f156"; 1621 | } 1622 | 1623 | .fa-cny:before, 1624 | .fa-rmb:before, 1625 | .fa-yen:before, 1626 | .fa-jpy:before { 1627 | content: "\f157"; 1628 | } 1629 | 1630 | .fa-ruble:before, 1631 | .fa-rouble:before, 1632 | .fa-rub:before { 1633 | content: "\f158"; 1634 | } 1635 | 1636 | .fa-won:before, 1637 | .fa-krw:before { 1638 | content: "\f159"; 1639 | } 1640 | 1641 | .fa-bitcoin:before, 1642 | .fa-btc:before { 1643 | content: "\f15a"; 1644 | } 1645 | 1646 | .fa-file:before { 1647 | content: "\f15b"; 1648 | } 1649 | 1650 | .fa-file-text:before { 1651 | content: "\f15c"; 1652 | } 1653 | 1654 | .fa-sort-alpha-asc:before { 1655 | content: "\f15d"; 1656 | } 1657 | 1658 | .fa-sort-alpha-desc:before { 1659 | content: "\f15e"; 1660 | } 1661 | 1662 | .fa-sort-amount-asc:before { 1663 | content: "\f160"; 1664 | } 1665 | 1666 | .fa-sort-amount-desc:before { 1667 | content: "\f161"; 1668 | } 1669 | 1670 | .fa-sort-numeric-asc:before { 1671 | content: "\f162"; 1672 | } 1673 | 1674 | .fa-sort-numeric-desc:before { 1675 | content: "\f163"; 1676 | } 1677 | 1678 | .fa-thumbs-up:before { 1679 | content: "\f164"; 1680 | } 1681 | 1682 | .fa-thumbs-down:before { 1683 | content: "\f165"; 1684 | } 1685 | 1686 | .fa-youtube-square:before { 1687 | content: "\f166"; 1688 | } 1689 | 1690 | .fa-youtube:before { 1691 | content: "\f167"; 1692 | } 1693 | 1694 | .fa-xing:before { 1695 | content: "\f168"; 1696 | } 1697 | 1698 | .fa-xing-square:before { 1699 | content: "\f169"; 1700 | } 1701 | 1702 | .fa-youtube-play:before { 1703 | content: "\f16a"; 1704 | } 1705 | 1706 | .fa-dropbox:before { 1707 | content: "\f16b"; 1708 | } 1709 | 1710 | .fa-stack-overflow:before { 1711 | content: "\f16c"; 1712 | } 1713 | 1714 | .fa-instagram:before { 1715 | content: "\f16d"; 1716 | } 1717 | 1718 | .fa-flickr:before { 1719 | content: "\f16e"; 1720 | } 1721 | 1722 | .fa-adn:before { 1723 | content: "\f170"; 1724 | } 1725 | 1726 | .fa-bitbucket:before { 1727 | content: "\f171"; 1728 | } 1729 | 1730 | .fa-bitbucket-square:before { 1731 | content: "\f172"; 1732 | } 1733 | 1734 | .fa-tumblr:before { 1735 | content: "\f173"; 1736 | } 1737 | 1738 | .fa-tumblr-square:before { 1739 | content: "\f174"; 1740 | } 1741 | 1742 | .fa-long-arrow-down:before { 1743 | content: "\f175"; 1744 | } 1745 | 1746 | .fa-long-arrow-up:before { 1747 | content: "\f176"; 1748 | } 1749 | 1750 | .fa-long-arrow-left:before { 1751 | content: "\f177"; 1752 | } 1753 | 1754 | .fa-long-arrow-right:before { 1755 | content: "\f178"; 1756 | } 1757 | 1758 | .fa-apple:before { 1759 | content: "\f179"; 1760 | } 1761 | 1762 | .fa-windows:before { 1763 | content: "\f17a"; 1764 | } 1765 | 1766 | .fa-android:before { 1767 | content: "\f17b"; 1768 | } 1769 | 1770 | .fa-linux:before { 1771 | content: "\f17c"; 1772 | } 1773 | 1774 | .fa-dribbble:before { 1775 | content: "\f17d"; 1776 | } 1777 | 1778 | .fa-skype:before { 1779 | content: "\f17e"; 1780 | } 1781 | 1782 | .fa-foursquare:before { 1783 | content: "\f180"; 1784 | } 1785 | 1786 | .fa-trello:before { 1787 | content: "\f181"; 1788 | } 1789 | 1790 | .fa-female:before { 1791 | content: "\f182"; 1792 | } 1793 | 1794 | .fa-male:before { 1795 | content: "\f183"; 1796 | } 1797 | 1798 | .fa-gittip:before, 1799 | .fa-gratipay:before { 1800 | content: "\f184"; 1801 | } 1802 | 1803 | .fa-sun-o:before { 1804 | content: "\f185"; 1805 | } 1806 | 1807 | .fa-moon-o:before { 1808 | content: "\f186"; 1809 | } 1810 | 1811 | .fa-archive:before { 1812 | content: "\f187"; 1813 | } 1814 | 1815 | .fa-bug:before { 1816 | content: "\f188"; 1817 | } 1818 | 1819 | .fa-vk:before { 1820 | content: "\f189"; 1821 | } 1822 | 1823 | .fa-weibo:before { 1824 | content: "\f18a"; 1825 | } 1826 | 1827 | .fa-renren:before { 1828 | content: "\f18b"; 1829 | } 1830 | 1831 | .fa-pagelines:before { 1832 | content: "\f18c"; 1833 | } 1834 | 1835 | .fa-stack-exchange:before { 1836 | content: "\f18d"; 1837 | } 1838 | 1839 | .fa-arrow-circle-o-right:before { 1840 | content: "\f18e"; 1841 | } 1842 | 1843 | .fa-arrow-circle-o-left:before { 1844 | content: "\f190"; 1845 | } 1846 | 1847 | .fa-toggle-left:before, 1848 | .fa-caret-square-o-left:before { 1849 | content: "\f191"; 1850 | } 1851 | 1852 | .fa-dot-circle-o:before { 1853 | content: "\f192"; 1854 | } 1855 | 1856 | .fa-wheelchair:before { 1857 | content: "\f193"; 1858 | } 1859 | 1860 | .fa-vimeo-square:before { 1861 | content: "\f194"; 1862 | } 1863 | 1864 | .fa-turkish-lira:before, 1865 | .fa-try:before { 1866 | content: "\f195"; 1867 | } 1868 | 1869 | .fa-plus-square-o:before { 1870 | content: "\f196"; 1871 | } 1872 | 1873 | .fa-space-shuttle:before { 1874 | content: "\f197"; 1875 | } 1876 | 1877 | .fa-slack:before { 1878 | content: "\f198"; 1879 | } 1880 | 1881 | .fa-envelope-square:before { 1882 | content: "\f199"; 1883 | } 1884 | 1885 | .fa-wordpress:before { 1886 | content: "\f19a"; 1887 | } 1888 | 1889 | .fa-openid:before { 1890 | content: "\f19b"; 1891 | } 1892 | 1893 | .fa-institution:before, 1894 | .fa-bank:before, 1895 | .fa-university:before { 1896 | content: "\f19c"; 1897 | } 1898 | 1899 | .fa-mortar-board:before, 1900 | .fa-graduation-cap:before { 1901 | content: "\f19d"; 1902 | } 1903 | 1904 | .fa-yahoo:before { 1905 | content: "\f19e"; 1906 | } 1907 | 1908 | .fa-google:before { 1909 | content: "\f1a0"; 1910 | } 1911 | 1912 | .fa-reddit:before { 1913 | content: "\f1a1"; 1914 | } 1915 | 1916 | .fa-reddit-square:before { 1917 | content: "\f1a2"; 1918 | } 1919 | 1920 | .fa-stumbleupon-circle:before { 1921 | content: "\f1a3"; 1922 | } 1923 | 1924 | .fa-stumbleupon:before { 1925 | content: "\f1a4"; 1926 | } 1927 | 1928 | .fa-delicious:before { 1929 | content: "\f1a5"; 1930 | } 1931 | 1932 | .fa-digg:before { 1933 | content: "\f1a6"; 1934 | } 1935 | 1936 | .fa-pied-piper-pp:before { 1937 | content: "\f1a7"; 1938 | } 1939 | 1940 | .fa-pied-piper-alt:before { 1941 | content: "\f1a8"; 1942 | } 1943 | 1944 | .fa-drupal:before { 1945 | content: "\f1a9"; 1946 | } 1947 | 1948 | .fa-joomla:before { 1949 | content: "\f1aa"; 1950 | } 1951 | 1952 | .fa-language:before { 1953 | content: "\f1ab"; 1954 | } 1955 | 1956 | .fa-fax:before { 1957 | content: "\f1ac"; 1958 | } 1959 | 1960 | .fa-building:before { 1961 | content: "\f1ad"; 1962 | } 1963 | 1964 | .fa-child:before { 1965 | content: "\f1ae"; 1966 | } 1967 | 1968 | .fa-paw:before { 1969 | content: "\f1b0"; 1970 | } 1971 | 1972 | .fa-spoon:before { 1973 | content: "\f1b1"; 1974 | } 1975 | 1976 | .fa-cube:before { 1977 | content: "\f1b2"; 1978 | } 1979 | 1980 | .fa-cubes:before { 1981 | content: "\f1b3"; 1982 | } 1983 | 1984 | .fa-behance:before { 1985 | content: "\f1b4"; 1986 | } 1987 | 1988 | .fa-behance-square:before { 1989 | content: "\f1b5"; 1990 | } 1991 | 1992 | .fa-steam:before { 1993 | content: "\f1b6"; 1994 | } 1995 | 1996 | .fa-steam-square:before { 1997 | content: "\f1b7"; 1998 | } 1999 | 2000 | .fa-recycle:before { 2001 | content: "\f1b8"; 2002 | } 2003 | 2004 | .fa-automobile:before, 2005 | .fa-car:before { 2006 | content: "\f1b9"; 2007 | } 2008 | 2009 | .fa-cab:before, 2010 | .fa-taxi:before { 2011 | content: "\f1ba"; 2012 | } 2013 | 2014 | .fa-tree:before { 2015 | content: "\f1bb"; 2016 | } 2017 | 2018 | .fa-spotify:before { 2019 | content: "\f1bc"; 2020 | } 2021 | 2022 | .fa-deviantart:before { 2023 | content: "\f1bd"; 2024 | } 2025 | 2026 | .fa-soundcloud:before { 2027 | content: "\f1be"; 2028 | } 2029 | 2030 | .fa-database:before { 2031 | content: "\f1c0"; 2032 | } 2033 | 2034 | .fa-file-pdf-o:before { 2035 | content: "\f1c1"; 2036 | } 2037 | 2038 | .fa-file-word-o:before { 2039 | content: "\f1c2"; 2040 | } 2041 | 2042 | .fa-file-excel-o:before { 2043 | content: "\f1c3"; 2044 | } 2045 | 2046 | .fa-file-powerpoint-o:before { 2047 | content: "\f1c4"; 2048 | } 2049 | 2050 | .fa-file-photo-o:before, 2051 | .fa-file-picture-o:before, 2052 | .fa-file-image-o:before { 2053 | content: "\f1c5"; 2054 | } 2055 | 2056 | .fa-file-zip-o:before, 2057 | .fa-file-archive-o:before { 2058 | content: "\f1c6"; 2059 | } 2060 | 2061 | .fa-file-sound-o:before, 2062 | .fa-file-audio-o:before { 2063 | content: "\f1c7"; 2064 | } 2065 | 2066 | .fa-file-movie-o:before, 2067 | .fa-file-video-o:before { 2068 | content: "\f1c8"; 2069 | } 2070 | 2071 | .fa-file-code-o:before { 2072 | content: "\f1c9"; 2073 | } 2074 | 2075 | .fa-vine:before { 2076 | content: "\f1ca"; 2077 | } 2078 | 2079 | .fa-codepen:before { 2080 | content: "\f1cb"; 2081 | } 2082 | 2083 | .fa-jsfiddle:before { 2084 | content: "\f1cc"; 2085 | } 2086 | 2087 | .fa-life-bouy:before, 2088 | .fa-life-buoy:before, 2089 | .fa-life-saver:before, 2090 | .fa-support:before, 2091 | .fa-life-ring:before { 2092 | content: "\f1cd"; 2093 | } 2094 | 2095 | .fa-circle-o-notch:before { 2096 | content: "\f1ce"; 2097 | } 2098 | 2099 | .fa-ra:before, 2100 | .fa-resistance:before, 2101 | .fa-rebel:before { 2102 | content: "\f1d0"; 2103 | } 2104 | 2105 | .fa-ge:before, 2106 | .fa-empire:before { 2107 | content: "\f1d1"; 2108 | } 2109 | 2110 | .fa-git-square:before { 2111 | content: "\f1d2"; 2112 | } 2113 | 2114 | .fa-git:before { 2115 | content: "\f1d3"; 2116 | } 2117 | 2118 | .fa-y-combinator-square:before, 2119 | .fa-yc-square:before, 2120 | .fa-hacker-news:before { 2121 | content: "\f1d4"; 2122 | } 2123 | 2124 | .fa-tencent-weibo:before { 2125 | content: "\f1d5"; 2126 | } 2127 | 2128 | .fa-qq:before { 2129 | content: "\f1d6"; 2130 | } 2131 | 2132 | .fa-wechat:before, 2133 | .fa-weixin:before { 2134 | content: "\f1d7"; 2135 | } 2136 | 2137 | .fa-send:before, 2138 | .fa-paper-plane:before { 2139 | content: "\f1d8"; 2140 | } 2141 | 2142 | .fa-send-o:before, 2143 | .fa-paper-plane-o:before { 2144 | content: "\f1d9"; 2145 | } 2146 | 2147 | .fa-history:before { 2148 | content: "\f1da"; 2149 | } 2150 | 2151 | .fa-circle-thin:before { 2152 | content: "\f1db"; 2153 | } 2154 | 2155 | .fa-header:before { 2156 | content: "\f1dc"; 2157 | } 2158 | 2159 | .fa-paragraph:before { 2160 | content: "\f1dd"; 2161 | } 2162 | 2163 | .fa-sliders:before { 2164 | content: "\f1de"; 2165 | } 2166 | 2167 | .fa-share-alt:before { 2168 | content: "\f1e0"; 2169 | } 2170 | 2171 | .fa-share-alt-square:before { 2172 | content: "\f1e1"; 2173 | } 2174 | 2175 | .fa-bomb:before { 2176 | content: "\f1e2"; 2177 | } 2178 | 2179 | .fa-soccer-ball-o:before, 2180 | .fa-futbol-o:before { 2181 | content: "\f1e3"; 2182 | } 2183 | 2184 | .fa-tty:before { 2185 | content: "\f1e4"; 2186 | } 2187 | 2188 | .fa-binoculars:before { 2189 | content: "\f1e5"; 2190 | } 2191 | 2192 | .fa-plug:before { 2193 | content: "\f1e6"; 2194 | } 2195 | 2196 | .fa-slideshare:before { 2197 | content: "\f1e7"; 2198 | } 2199 | 2200 | .fa-twitch:before { 2201 | content: "\f1e8"; 2202 | } 2203 | 2204 | .fa-yelp:before { 2205 | content: "\f1e9"; 2206 | } 2207 | 2208 | .fa-newspaper-o:before { 2209 | content: "\f1ea"; 2210 | } 2211 | 2212 | .fa-wifi:before { 2213 | content: "\f1eb"; 2214 | } 2215 | 2216 | .fa-calculator:before { 2217 | content: "\f1ec"; 2218 | } 2219 | 2220 | .fa-paypal:before { 2221 | content: "\f1ed"; 2222 | } 2223 | 2224 | .fa-google-wallet:before { 2225 | content: "\f1ee"; 2226 | } 2227 | 2228 | .fa-cc-visa:before { 2229 | content: "\f1f0"; 2230 | } 2231 | 2232 | .fa-cc-mastercard:before { 2233 | content: "\f1f1"; 2234 | } 2235 | 2236 | .fa-cc-discover:before { 2237 | content: "\f1f2"; 2238 | } 2239 | 2240 | .fa-cc-amex:before { 2241 | content: "\f1f3"; 2242 | } 2243 | 2244 | .fa-cc-paypal:before { 2245 | content: "\f1f4"; 2246 | } 2247 | 2248 | .fa-cc-stripe:before { 2249 | content: "\f1f5"; 2250 | } 2251 | 2252 | .fa-bell-slash:before { 2253 | content: "\f1f6"; 2254 | } 2255 | 2256 | .fa-bell-slash-o:before { 2257 | content: "\f1f7"; 2258 | } 2259 | 2260 | .fa-trash:before { 2261 | content: "\f1f8"; 2262 | } 2263 | 2264 | .fa-copyright:before { 2265 | content: "\f1f9"; 2266 | } 2267 | 2268 | .fa-at:before { 2269 | content: "\f1fa"; 2270 | } 2271 | 2272 | .fa-eyedropper:before { 2273 | content: "\f1fb"; 2274 | } 2275 | 2276 | .fa-paint-brush:before { 2277 | content: "\f1fc"; 2278 | } 2279 | 2280 | .fa-birthday-cake:before { 2281 | content: "\f1fd"; 2282 | } 2283 | 2284 | .fa-area-chart:before { 2285 | content: "\f1fe"; 2286 | } 2287 | 2288 | .fa-pie-chart:before { 2289 | content: "\f200"; 2290 | } 2291 | 2292 | .fa-line-chart:before { 2293 | content: "\f201"; 2294 | } 2295 | 2296 | .fa-lastfm:before { 2297 | content: "\f202"; 2298 | } 2299 | 2300 | .fa-lastfm-square:before { 2301 | content: "\f203"; 2302 | } 2303 | 2304 | .fa-toggle-off:before { 2305 | content: "\f204"; 2306 | } 2307 | 2308 | .fa-toggle-on:before { 2309 | content: "\f205"; 2310 | } 2311 | 2312 | .fa-bicycle:before { 2313 | content: "\f206"; 2314 | } 2315 | 2316 | .fa-bus:before { 2317 | content: "\f207"; 2318 | } 2319 | 2320 | .fa-ioxhost:before { 2321 | content: "\f208"; 2322 | } 2323 | 2324 | .fa-angellist:before { 2325 | content: "\f209"; 2326 | } 2327 | 2328 | .fa-cc:before { 2329 | content: "\f20a"; 2330 | } 2331 | 2332 | .fa-shekel:before, 2333 | .fa-sheqel:before, 2334 | .fa-ils:before { 2335 | content: "\f20b"; 2336 | } 2337 | 2338 | .fa-meanpath:before { 2339 | content: "\f20c"; 2340 | } 2341 | 2342 | .fa-buysellads:before { 2343 | content: "\f20d"; 2344 | } 2345 | 2346 | .fa-connectdevelop:before { 2347 | content: "\f20e"; 2348 | } 2349 | 2350 | .fa-dashcube:before { 2351 | content: "\f210"; 2352 | } 2353 | 2354 | .fa-forumbee:before { 2355 | content: "\f211"; 2356 | } 2357 | 2358 | .fa-leanpub:before { 2359 | content: "\f212"; 2360 | } 2361 | 2362 | .fa-sellsy:before { 2363 | content: "\f213"; 2364 | } 2365 | 2366 | .fa-shirtsinbulk:before { 2367 | content: "\f214"; 2368 | } 2369 | 2370 | .fa-simplybuilt:before { 2371 | content: "\f215"; 2372 | } 2373 | 2374 | .fa-skyatlas:before { 2375 | content: "\f216"; 2376 | } 2377 | 2378 | .fa-cart-plus:before { 2379 | content: "\f217"; 2380 | } 2381 | 2382 | .fa-cart-arrow-down:before { 2383 | content: "\f218"; 2384 | } 2385 | 2386 | .fa-diamond:before { 2387 | content: "\f219"; 2388 | } 2389 | 2390 | .fa-ship:before { 2391 | content: "\f21a"; 2392 | } 2393 | 2394 | .fa-user-secret:before { 2395 | content: "\f21b"; 2396 | } 2397 | 2398 | .fa-motorcycle:before { 2399 | content: "\f21c"; 2400 | } 2401 | 2402 | .fa-street-view:before { 2403 | content: "\f21d"; 2404 | } 2405 | 2406 | .fa-heartbeat:before { 2407 | content: "\f21e"; 2408 | } 2409 | 2410 | .fa-venus:before { 2411 | content: "\f221"; 2412 | } 2413 | 2414 | .fa-mars:before { 2415 | content: "\f222"; 2416 | } 2417 | 2418 | .fa-mercury:before { 2419 | content: "\f223"; 2420 | } 2421 | 2422 | .fa-intersex:before, 2423 | .fa-transgender:before { 2424 | content: "\f224"; 2425 | } 2426 | 2427 | .fa-transgender-alt:before { 2428 | content: "\f225"; 2429 | } 2430 | 2431 | .fa-venus-double:before { 2432 | content: "\f226"; 2433 | } 2434 | 2435 | .fa-mars-double:before { 2436 | content: "\f227"; 2437 | } 2438 | 2439 | .fa-venus-mars:before { 2440 | content: "\f228"; 2441 | } 2442 | 2443 | .fa-mars-stroke:before { 2444 | content: "\f229"; 2445 | } 2446 | 2447 | .fa-mars-stroke-v:before { 2448 | content: "\f22a"; 2449 | } 2450 | 2451 | .fa-mars-stroke-h:before { 2452 | content: "\f22b"; 2453 | } 2454 | 2455 | .fa-neuter:before { 2456 | content: "\f22c"; 2457 | } 2458 | 2459 | .fa-genderless:before { 2460 | content: "\f22d"; 2461 | } 2462 | 2463 | .fa-facebook-official:before { 2464 | content: "\f230"; 2465 | } 2466 | 2467 | .fa-pinterest-p:before { 2468 | content: "\f231"; 2469 | } 2470 | 2471 | .fa-whatsapp:before { 2472 | content: "\f232"; 2473 | } 2474 | 2475 | .fa-server:before { 2476 | content: "\f233"; 2477 | } 2478 | 2479 | .fa-user-plus:before { 2480 | content: "\f234"; 2481 | } 2482 | 2483 | .fa-user-times:before { 2484 | content: "\f235"; 2485 | } 2486 | 2487 | .fa-hotel:before, 2488 | .fa-bed:before { 2489 | content: "\f236"; 2490 | } 2491 | 2492 | .fa-viacoin:before { 2493 | content: "\f237"; 2494 | } 2495 | 2496 | .fa-train:before { 2497 | content: "\f238"; 2498 | } 2499 | 2500 | .fa-subway:before { 2501 | content: "\f239"; 2502 | } 2503 | 2504 | .fa-medium:before { 2505 | content: "\f23a"; 2506 | } 2507 | 2508 | .fa-yc:before, 2509 | .fa-y-combinator:before { 2510 | content: "\f23b"; 2511 | } 2512 | 2513 | .fa-optin-monster:before { 2514 | content: "\f23c"; 2515 | } 2516 | 2517 | .fa-opencart:before { 2518 | content: "\f23d"; 2519 | } 2520 | 2521 | .fa-expeditedssl:before { 2522 | content: "\f23e"; 2523 | } 2524 | 2525 | .fa-battery-4:before, 2526 | .fa-battery:before, 2527 | .fa-battery-full:before { 2528 | content: "\f240"; 2529 | } 2530 | 2531 | .fa-battery-3:before, 2532 | .fa-battery-three-quarters:before { 2533 | content: "\f241"; 2534 | } 2535 | 2536 | .fa-battery-2:before, 2537 | .fa-battery-half:before { 2538 | content: "\f242"; 2539 | } 2540 | 2541 | .fa-battery-1:before, 2542 | .fa-battery-quarter:before { 2543 | content: "\f243"; 2544 | } 2545 | 2546 | .fa-battery-0:before, 2547 | .fa-battery-empty:before { 2548 | content: "\f244"; 2549 | } 2550 | 2551 | .fa-mouse-pointer:before { 2552 | content: "\f245"; 2553 | } 2554 | 2555 | .fa-i-cursor:before { 2556 | content: "\f246"; 2557 | } 2558 | 2559 | .fa-object-group:before { 2560 | content: "\f247"; 2561 | } 2562 | 2563 | .fa-object-ungroup:before { 2564 | content: "\f248"; 2565 | } 2566 | 2567 | .fa-sticky-note:before { 2568 | content: "\f249"; 2569 | } 2570 | 2571 | .fa-sticky-note-o:before { 2572 | content: "\f24a"; 2573 | } 2574 | 2575 | .fa-cc-jcb:before { 2576 | content: "\f24b"; 2577 | } 2578 | 2579 | .fa-cc-diners-club:before { 2580 | content: "\f24c"; 2581 | } 2582 | 2583 | .fa-clone:before { 2584 | content: "\f24d"; 2585 | } 2586 | 2587 | .fa-balance-scale:before { 2588 | content: "\f24e"; 2589 | } 2590 | 2591 | .fa-hourglass-o:before { 2592 | content: "\f250"; 2593 | } 2594 | 2595 | .fa-hourglass-1:before, 2596 | .fa-hourglass-start:before { 2597 | content: "\f251"; 2598 | } 2599 | 2600 | .fa-hourglass-2:before, 2601 | .fa-hourglass-half:before { 2602 | content: "\f252"; 2603 | } 2604 | 2605 | .fa-hourglass-3:before, 2606 | .fa-hourglass-end:before { 2607 | content: "\f253"; 2608 | } 2609 | 2610 | .fa-hourglass:before { 2611 | content: "\f254"; 2612 | } 2613 | 2614 | .fa-hand-grab-o:before, 2615 | .fa-hand-rock-o:before { 2616 | content: "\f255"; 2617 | } 2618 | 2619 | .fa-hand-stop-o:before, 2620 | .fa-hand-paper-o:before { 2621 | content: "\f256"; 2622 | } 2623 | 2624 | .fa-hand-scissors-o:before { 2625 | content: "\f257"; 2626 | } 2627 | 2628 | .fa-hand-lizard-o:before { 2629 | content: "\f258"; 2630 | } 2631 | 2632 | .fa-hand-spock-o:before { 2633 | content: "\f259"; 2634 | } 2635 | 2636 | .fa-hand-pointer-o:before { 2637 | content: "\f25a"; 2638 | } 2639 | 2640 | .fa-hand-peace-o:before { 2641 | content: "\f25b"; 2642 | } 2643 | 2644 | .fa-trademark:before { 2645 | content: "\f25c"; 2646 | } 2647 | 2648 | .fa-registered:before { 2649 | content: "\f25d"; 2650 | } 2651 | 2652 | .fa-creative-commons:before { 2653 | content: "\f25e"; 2654 | } 2655 | 2656 | .fa-gg:before { 2657 | content: "\f260"; 2658 | } 2659 | 2660 | .fa-gg-circle:before { 2661 | content: "\f261"; 2662 | } 2663 | 2664 | .fa-tripadvisor:before { 2665 | content: "\f262"; 2666 | } 2667 | 2668 | .fa-odnoklassniki:before { 2669 | content: "\f263"; 2670 | } 2671 | 2672 | .fa-odnoklassniki-square:before { 2673 | content: "\f264"; 2674 | } 2675 | 2676 | .fa-get-pocket:before { 2677 | content: "\f265"; 2678 | } 2679 | 2680 | .fa-wikipedia-w:before { 2681 | content: "\f266"; 2682 | } 2683 | 2684 | .fa-safari:before { 2685 | content: "\f267"; 2686 | } 2687 | 2688 | .fa-chrome:before { 2689 | content: "\f268"; 2690 | } 2691 | 2692 | .fa-firefox:before { 2693 | content: "\f269"; 2694 | } 2695 | 2696 | .fa-opera:before { 2697 | content: "\f26a"; 2698 | } 2699 | 2700 | .fa-internet-explorer:before { 2701 | content: "\f26b"; 2702 | } 2703 | 2704 | .fa-tv:before, 2705 | .fa-television:before { 2706 | content: "\f26c"; 2707 | } 2708 | 2709 | .fa-contao:before { 2710 | content: "\f26d"; 2711 | } 2712 | 2713 | .fa-500px:before { 2714 | content: "\f26e"; 2715 | } 2716 | 2717 | .fa-amazon:before { 2718 | content: "\f270"; 2719 | } 2720 | 2721 | .fa-calendar-plus-o:before { 2722 | content: "\f271"; 2723 | } 2724 | 2725 | .fa-calendar-minus-o:before { 2726 | content: "\f272"; 2727 | } 2728 | 2729 | .fa-calendar-times-o:before { 2730 | content: "\f273"; 2731 | } 2732 | 2733 | .fa-calendar-check-o:before { 2734 | content: "\f274"; 2735 | } 2736 | 2737 | .fa-industry:before { 2738 | content: "\f275"; 2739 | } 2740 | 2741 | .fa-map-pin:before { 2742 | content: "\f276"; 2743 | } 2744 | 2745 | .fa-map-signs:before { 2746 | content: "\f277"; 2747 | } 2748 | 2749 | .fa-map-o:before { 2750 | content: "\f278"; 2751 | } 2752 | 2753 | .fa-map:before { 2754 | content: "\f279"; 2755 | } 2756 | 2757 | .fa-commenting:before { 2758 | content: "\f27a"; 2759 | } 2760 | 2761 | .fa-commenting-o:before { 2762 | content: "\f27b"; 2763 | } 2764 | 2765 | .fa-houzz:before { 2766 | content: "\f27c"; 2767 | } 2768 | 2769 | .fa-vimeo:before { 2770 | content: "\f27d"; 2771 | } 2772 | 2773 | .fa-black-tie:before { 2774 | content: "\f27e"; 2775 | } 2776 | 2777 | .fa-fonticons:before { 2778 | content: "\f280"; 2779 | } 2780 | 2781 | .fa-reddit-alien:before { 2782 | content: "\f281"; 2783 | } 2784 | 2785 | .fa-edge:before { 2786 | content: "\f282"; 2787 | } 2788 | 2789 | .fa-credit-card-alt:before { 2790 | content: "\f283"; 2791 | } 2792 | 2793 | .fa-codiepie:before { 2794 | content: "\f284"; 2795 | } 2796 | 2797 | .fa-modx:before { 2798 | content: "\f285"; 2799 | } 2800 | 2801 | .fa-fort-awesome:before { 2802 | content: "\f286"; 2803 | } 2804 | 2805 | .fa-usb:before { 2806 | content: "\f287"; 2807 | } 2808 | 2809 | .fa-product-hunt:before { 2810 | content: "\f288"; 2811 | } 2812 | 2813 | .fa-mixcloud:before { 2814 | content: "\f289"; 2815 | } 2816 | 2817 | .fa-scribd:before { 2818 | content: "\f28a"; 2819 | } 2820 | 2821 | .fa-pause-circle:before { 2822 | content: "\f28b"; 2823 | } 2824 | 2825 | .fa-pause-circle-o:before { 2826 | content: "\f28c"; 2827 | } 2828 | 2829 | .fa-stop-circle:before { 2830 | content: "\f28d"; 2831 | } 2832 | 2833 | .fa-stop-circle-o:before { 2834 | content: "\f28e"; 2835 | } 2836 | 2837 | .fa-shopping-bag:before { 2838 | content: "\f290"; 2839 | } 2840 | 2841 | .fa-shopping-basket:before { 2842 | content: "\f291"; 2843 | } 2844 | 2845 | .fa-hashtag:before { 2846 | content: "\f292"; 2847 | } 2848 | 2849 | .fa-bluetooth:before { 2850 | content: "\f293"; 2851 | } 2852 | 2853 | .fa-bluetooth-b:before { 2854 | content: "\f294"; 2855 | } 2856 | 2857 | .fa-percent:before { 2858 | content: "\f295"; 2859 | } 2860 | 2861 | .fa-gitlab:before { 2862 | content: "\f296"; 2863 | } 2864 | 2865 | .fa-wpbeginner:before { 2866 | content: "\f297"; 2867 | } 2868 | 2869 | .fa-wpforms:before { 2870 | content: "\f298"; 2871 | } 2872 | 2873 | .fa-envira:before { 2874 | content: "\f299"; 2875 | } 2876 | 2877 | .fa-universal-access:before { 2878 | content: "\f29a"; 2879 | } 2880 | 2881 | .fa-wheelchair-alt:before { 2882 | content: "\f29b"; 2883 | } 2884 | 2885 | .fa-question-circle-o:before { 2886 | content: "\f29c"; 2887 | } 2888 | 2889 | .fa-blind:before { 2890 | content: "\f29d"; 2891 | } 2892 | 2893 | .fa-audio-description:before { 2894 | content: "\f29e"; 2895 | } 2896 | 2897 | .fa-volume-control-phone:before { 2898 | content: "\f2a0"; 2899 | } 2900 | 2901 | .fa-braille:before { 2902 | content: "\f2a1"; 2903 | } 2904 | 2905 | .fa-assistive-listening-systems:before { 2906 | content: "\f2a2"; 2907 | } 2908 | 2909 | .fa-asl-interpreting:before, 2910 | .fa-american-sign-language-interpreting:before { 2911 | content: "\f2a3"; 2912 | } 2913 | 2914 | .fa-deafness:before, 2915 | .fa-hard-of-hearing:before, 2916 | .fa-deaf:before { 2917 | content: "\f2a4"; 2918 | } 2919 | 2920 | .fa-glide:before { 2921 | content: "\f2a5"; 2922 | } 2923 | 2924 | .fa-glide-g:before { 2925 | content: "\f2a6"; 2926 | } 2927 | 2928 | .fa-signing:before, 2929 | .fa-sign-language:before { 2930 | content: "\f2a7"; 2931 | } 2932 | 2933 | .fa-low-vision:before { 2934 | content: "\f2a8"; 2935 | } 2936 | 2937 | .fa-viadeo:before { 2938 | content: "\f2a9"; 2939 | } 2940 | 2941 | .fa-viadeo-square:before { 2942 | content: "\f2aa"; 2943 | } 2944 | 2945 | .fa-snapchat:before { 2946 | content: "\f2ab"; 2947 | } 2948 | 2949 | .fa-snapchat-ghost:before { 2950 | content: "\f2ac"; 2951 | } 2952 | 2953 | .fa-snapchat-square:before { 2954 | content: "\f2ad"; 2955 | } 2956 | 2957 | .fa-pied-piper:before { 2958 | content: "\f2ae"; 2959 | } 2960 | 2961 | .fa-first-order:before { 2962 | content: "\f2b0"; 2963 | } 2964 | 2965 | .fa-yoast:before { 2966 | content: "\f2b1"; 2967 | } 2968 | 2969 | .fa-themeisle:before { 2970 | content: "\f2b2"; 2971 | } 2972 | 2973 | .fa-google-plus-circle:before, 2974 | .fa-google-plus-official:before { 2975 | content: "\f2b3"; 2976 | } 2977 | 2978 | .fa-fa:before, 2979 | .fa-font-awesome:before { 2980 | content: "\f2b4"; 2981 | } 2982 | 2983 | .fa-handshake-o:before { 2984 | content: "\f2b5"; 2985 | } 2986 | 2987 | .fa-envelope-open:before { 2988 | content: "\f2b6"; 2989 | } 2990 | 2991 | .fa-envelope-open-o:before { 2992 | content: "\f2b7"; 2993 | } 2994 | 2995 | .fa-linode:before { 2996 | content: "\f2b8"; 2997 | } 2998 | 2999 | .fa-address-book:before { 3000 | content: "\f2b9"; 3001 | } 3002 | 3003 | .fa-address-book-o:before { 3004 | content: "\f2ba"; 3005 | } 3006 | 3007 | .fa-vcard:before, 3008 | .fa-address-card:before { 3009 | content: "\f2bb"; 3010 | } 3011 | 3012 | .fa-vcard-o:before, 3013 | .fa-address-card-o:before { 3014 | content: "\f2bc"; 3015 | } 3016 | 3017 | .fa-user-circle:before { 3018 | content: "\f2bd"; 3019 | } 3020 | 3021 | .fa-user-circle-o:before { 3022 | content: "\f2be"; 3023 | } 3024 | 3025 | .fa-user-o:before { 3026 | content: "\f2c0"; 3027 | } 3028 | 3029 | .fa-id-badge:before { 3030 | content: "\f2c1"; 3031 | } 3032 | 3033 | .fa-drivers-license:before, 3034 | .fa-id-card:before { 3035 | content: "\f2c2"; 3036 | } 3037 | 3038 | .fa-drivers-license-o:before, 3039 | .fa-id-card-o:before { 3040 | content: "\f2c3"; 3041 | } 3042 | 3043 | .fa-quora:before { 3044 | content: "\f2c4"; 3045 | } 3046 | 3047 | .fa-free-code-camp:before { 3048 | content: "\f2c5"; 3049 | } 3050 | 3051 | .fa-telegram:before { 3052 | content: "\f2c6"; 3053 | } 3054 | 3055 | .fa-thermometer-4:before, 3056 | .fa-thermometer:before, 3057 | .fa-thermometer-full:before { 3058 | content: "\f2c7"; 3059 | } 3060 | 3061 | .fa-thermometer-3:before, 3062 | .fa-thermometer-three-quarters:before { 3063 | content: "\f2c8"; 3064 | } 3065 | 3066 | .fa-thermometer-2:before, 3067 | .fa-thermometer-half:before { 3068 | content: "\f2c9"; 3069 | } 3070 | 3071 | .fa-thermometer-1:before, 3072 | .fa-thermometer-quarter:before { 3073 | content: "\f2ca"; 3074 | } 3075 | 3076 | .fa-thermometer-0:before, 3077 | .fa-thermometer-empty:before { 3078 | content: "\f2cb"; 3079 | } 3080 | 3081 | .fa-shower:before { 3082 | content: "\f2cc"; 3083 | } 3084 | 3085 | .fa-bathtub:before, 3086 | .fa-s15:before, 3087 | .fa-bath:before { 3088 | content: "\f2cd"; 3089 | } 3090 | 3091 | .fa-podcast:before { 3092 | content: "\f2ce"; 3093 | } 3094 | 3095 | .fa-window-maximize:before { 3096 | content: "\f2d0"; 3097 | } 3098 | 3099 | .fa-window-minimize:before { 3100 | content: "\f2d1"; 3101 | } 3102 | 3103 | .fa-window-restore:before { 3104 | content: "\f2d2"; 3105 | } 3106 | 3107 | .fa-times-rectangle:before, 3108 | .fa-window-close:before { 3109 | content: "\f2d3"; 3110 | } 3111 | 3112 | .fa-times-rectangle-o:before, 3113 | .fa-window-close-o:before { 3114 | content: "\f2d4"; 3115 | } 3116 | 3117 | .fa-bandcamp:before { 3118 | content: "\f2d5"; 3119 | } 3120 | 3121 | .fa-grav:before { 3122 | content: "\f2d6"; 3123 | } 3124 | 3125 | .fa-etsy:before { 3126 | content: "\f2d7"; 3127 | } 3128 | 3129 | .fa-imdb:before { 3130 | content: "\f2d8"; 3131 | } 3132 | 3133 | .fa-ravelry:before { 3134 | content: "\f2d9"; 3135 | } 3136 | 3137 | .fa-eercast:before { 3138 | content: "\f2da"; 3139 | } 3140 | 3141 | .fa-microchip:before { 3142 | content: "\f2db"; 3143 | } 3144 | 3145 | .fa-snowflake-o:before { 3146 | content: "\f2dc"; 3147 | } 3148 | 3149 | .fa-superpowers:before { 3150 | content: "\f2dd"; 3151 | } 3152 | 3153 | .fa-wpexplorer:before { 3154 | content: "\f2de"; 3155 | } 3156 | 3157 | .fa-meetup:before { 3158 | content: "\f2e0"; 3159 | } 3160 | -------------------------------------------------------------------------------- /public/personalizations/recurse-com__header.html: -------------------------------------------------------------------------------- 1 | 958 | 959 | 974 | -------------------------------------------------------------------------------- /public/personalizations/register-service-worker.js: -------------------------------------------------------------------------------- 1 | const registerServiceWorker = async () => { 2 | if (!("serviceWorker" in navigator)) { 3 | console.warn("Couldn't register service worker"); 4 | return; 5 | } 6 | try { 7 | const registration = await navigator.serviceWorker.register( 8 | "/service-worker-driver.js", 9 | { 10 | scope: "/", 11 | }, 12 | ); 13 | if (registration.installing) { 14 | console.log("Service worker installing"); 15 | } else if (registration.waiting) { 16 | console.log("Service worker installed"); 17 | } else if (registration.active) { 18 | console.log("Service worker active"); 19 | } 20 | } catch (error) { 21 | console.error(`Registration failed with ${error}`, error); 22 | } 23 | }; 24 | 25 | navigator.serviceWorker.addEventListener("controllerchange", (event) => { 26 | console.log("controllerchange", event); 27 | }); 28 | 29 | registerServiceWorker(); 30 | -------------------------------------------------------------------------------- /public/personalizations/retro-futurism.html: -------------------------------------------------------------------------------- 1 | 191 | -------------------------------------------------------------------------------- /public/personalizations/show-fouc.css: -------------------------------------------------------------------------------- 1 | /* See https://stackoverflow.com/a/53364612 */ 2 | html { 3 | visibility: visible; 4 | opacity: 1; 5 | } 6 | -------------------------------------------------------------------------------- /public/recurse-community-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reedspool/rcverse/08f91adea24aaea17958a579b2593ea2cc973353/public/recurse-community-bot.png -------------------------------------------------------------------------------- /public/service-worker-driver.js: -------------------------------------------------------------------------------- 1 | // Cookie utilities from PPK https://www.quirksmode.org/js/cookies.html 2 | // Adapted to take in cookie as a parameter 3 | function extractCookieByName(cookie, name) { 4 | var nameEQ = name + "="; 5 | var ca = cookie.split(";"); 6 | for (var i = 0; i < ca.length; i++) { 7 | var c = ca[i]; 8 | while (c.charAt(0) == " ") c = c.substring(1, c.length); 9 | if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); 10 | } 11 | return null; 12 | } 13 | 14 | // To refresh user's caches automatically, change the number in this name. 15 | // That will cause `deleteOldCaches` to clean up the past one. So users might 16 | // still need to refresh after that switch happens? 17 | const RCVERSE_SERVICE_WORKER_CACHE_NAME = "rcverse-service-worker-cache-v8"; 18 | // Adapted from an MDN example 19 | const deleteOldCaches = async () => { 20 | const cacheKeepList = [RCVERSE_SERVICE_WORKER_CACHE_NAME]; 21 | const keyList = await caches.keys(); 22 | const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key)); 23 | await Promise.all(cachesToDelete.map((key) => caches.delete(key))); 24 | }; 25 | 26 | // Adapted from 27 | // https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#recovering_failed_requests 28 | const putInCache = async (request, response) => { 29 | const cache = await caches.open(RCVERSE_SERVICE_WORKER_CACHE_NAME); 30 | await cache.put(request, response); 31 | }; 32 | 33 | const cacheFirst = async (request) => { 34 | const responseFromCache = await caches.match(request); 35 | if (responseFromCache) { 36 | return responseFromCache; 37 | } 38 | const responseFromNetwork = await fetch(request); 39 | putInCache(request, responseFromNetwork.clone()); 40 | return responseFromNetwork; 41 | }; 42 | 43 | self.addEventListener("install", async function (event) { 44 | console.log( 45 | `Service worker installed. Initiating cache ${RCVERSE_SERVICE_WORKER_CACHE_NAME}`, 46 | event, 47 | ); 48 | 49 | event.waitUntil( 50 | caches.open(RCVERSE_SERVICE_WORKER_CACHE_NAME).then(function (cache) { 51 | return cache.addAll([ 52 | // TODO: Make index.html into a shell that can be cached and loaded instantly 53 | // "index.html" 54 | "favicon.ico", 55 | ]); 56 | }), 57 | ); 58 | 59 | // TODO: Not sure when skipWaiting is necessary. 60 | // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting 61 | self.skipWaiting(); 62 | }); 63 | 64 | self.addEventListener("activate", function (event) { 65 | console.log("Service worker activated", event); 66 | console.log("Service worker cleaning up old caches"); 67 | event.waitUntil(deleteOldCaches()); 68 | event.waitUntil(async () => { 69 | if (self.registration.navigationPreload) { 70 | await self.registration.navigationPreload.enable(); 71 | console.log("Service worker enabled navigation preload"); 72 | } 73 | }); 74 | console.log("Service worker attempting to claim"); 75 | event.waitUntil(clients.claim()); 76 | }); 77 | 78 | self.addEventListener("fetch", async function (event) { 79 | // Let the browser do its default thing for non-GET requests not matched above. 80 | if (event.request.method !== "GET") return; 81 | 82 | // Some things we want to cache after the first time we get them 83 | if ( 84 | // If it's a user photo/asset served or proxied through RC's infra 85 | event.request.url.match(/https:\/\/[^.]+.cloudfront.net\/assets\//) || 86 | // There's two URLs by which these are served? 87 | event.request.url.match( 88 | /https:\/\/assets.recurse.com\/rails\/active_storage\/representations/, 89 | ) || 90 | event.request.url.startsWith("https://unpkg.com/htmx.org") 91 | ) { 92 | event.respondWith(cacheFirst(event.request)); 93 | return; 94 | } 95 | 96 | // Default, check the cache or just go with the original 97 | // Note that this is wrapped in an immediately invoked function 98 | // to get an encompassing promise for event.respondWith to wait for 99 | event.respondWith( 100 | (async function () { 101 | const cachedResponse = await caches.match(event.request); 102 | if (cachedResponse) return cachedResponse; 103 | 104 | // Else, use the preloaded response, if it's there 105 | const response = await event.preloadResponse; 106 | if (response) return response; 107 | 108 | return fetch(event.request); 109 | })(), 110 | ); 111 | }); 112 | 113 | self.addEventListener("message", async (event) => { 114 | if (event?.data?.type !== "update_personalizations") return; 115 | const personalizations = event?.data?.payload?.map(({ url, ...rest }) => ({ 116 | url: decodeURIComponent(url), 117 | ...rest, 118 | })); 119 | const cache = await caches.open(RCVERSE_SERVICE_WORKER_CACHE_NAME); 120 | // TODO: In my vision for duplicating the back-end routes into this 121 | // service worker, the Personalizations cookie modifications might 122 | // be captured and entirely performed on the front-end. Unfortunately 123 | // that seems to be explicitly rejected as a usecase currently. 124 | // See https://stackoverflow.com/a/44445217 125 | const cacheReqs = await cache.keys(); 126 | cache.addAll( 127 | personalizations 128 | .filter( 129 | // `endsWith` to support paths, e.g. /personalizations/confetti.html 130 | ({ url, cache }) => 131 | cache && !cacheReqs.find((req) => req.url.endsWith(url)), 132 | ) 133 | .map(({ url }) => url), 134 | ); 135 | personalizations.forEach(({ url, cache: shouldCache }) => { 136 | if (shouldCache) return; 137 | const cachedReq = cacheReqs.find((req) => req.url.endsWith(url)); 138 | if (!cachedReq) return; 139 | cache.delete(cachedReq); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /scripts/generate-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | openssl req -nodes -new -x509 \ 3 | -keyout ./cert/server.key \ 4 | -out ./cert/server.cert \ 5 | -subj "/C=US/ST=State/L=City/O=company/OU=Com/CN=www.testserver.local" 6 | --------------------------------------------------------------------------------