├── src ├── db │ ├── 3-2.sql │ ├── 2-1.sql │ ├── 1-2.sql │ ├── 3-4.sql │ ├── 4-3.sql │ └── 2-3.sql ├── public │ ├── font │ │ └── nunito │ │ │ ├── Nunito-Black.ttf │ │ │ ├── Nunito-Bold.ttf │ │ │ ├── Nunito-Light.ttf │ │ │ ├── Nunito-Italic.ttf │ │ │ ├── Nunito-Regular.ttf │ │ │ ├── Nunito-SemiBold.ttf │ │ │ ├── Nunito-BoldItalic.ttf │ │ │ ├── Nunito-ExtraBold.ttf │ │ │ ├── Nunito-ExtraLight.ttf │ │ │ ├── Nunito-BlackItalic.ttf │ │ │ ├── Nunito-LightItalic.ttf │ │ │ ├── Nunito-ExtraBoldItalic.ttf │ │ │ ├── Nunito-SemiBoldItalic.ttf │ │ │ ├── Nunito-ExtraLightItalic.ttf │ │ │ └── OFL.txt │ ├── config │ │ └── index.js │ ├── svg │ │ ├── agree.svg │ │ ├── block.svg │ │ ├── disagree.svg │ │ └── stand.svg │ ├── js │ │ ├── index.js │ │ ├── utils.js │ │ ├── api.js │ │ ├── User │ │ │ ├── animation.js │ │ │ └── index.js │ │ ├── PeerConnection │ │ │ ├── ensureWebRTC.js │ │ │ ├── dataReceiver.js │ │ │ └── index.js │ │ ├── RoomMembership │ │ │ └── index.js │ │ ├── EphemeralMessage │ │ │ ├── sendText.js │ │ │ └── index.js │ │ ├── store │ │ │ └── index.js │ │ ├── Room │ │ │ ├── animation.js │ │ │ └── index.js │ │ ├── Togethernet │ │ │ ├── index.js │ │ │ └── systemMessage.js │ │ ├── Peer │ │ │ └── index.js │ │ ├── EphemeralMessageRenderer.js │ │ ├── ArchivedMessage │ │ │ └── index.js │ │ ├── RoomForm │ │ │ └── index.js │ │ ├── constants.js │ │ └── ArchivalSpace │ │ │ └── index.js │ ├── css │ │ ├── roomForm.css │ │ ├── users.css │ │ ├── reset.css │ │ ├── chat.css │ │ ├── navigation.css │ │ ├── oldEphemeral.css │ │ ├── archival.css │ │ ├── ephemeral.css │ │ └── style.css │ └── index.html ├── server │ ├── Archiver.js │ ├── PGClient.js │ └── SignalingServer.js └── index.js ├── .dev.env ├── .eslintrc.json ├── browserify.js ├── .gitignore ├── package.json ├── LICENSE.md ├── README.md └── CODEOFCONSENT0.1.md /src/db/3-2.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "messages"; -------------------------------------------------------------------------------- /.dev.env: -------------------------------------------------------------------------------- 1 | PG_HOST=localhost 2 | PG_PORT=5432 -------------------------------------------------------------------------------- /src/db/2-1.sql: -------------------------------------------------------------------------------- 1 | DROP TYPE "message_type"; -------------------------------------------------------------------------------- /src/db/1-2.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "message_type" AS ENUM ('thread', 'comment', 'text_message'); -------------------------------------------------------------------------------- /src/db/3-4.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "messages" 2 | ADD COLUMN thread_data jsonb, 3 | DROP COLUMN thread_previous_id, 4 | DROP COLUMN thread_next_id; -------------------------------------------------------------------------------- /src/db/4-3.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "messages" 2 | DROP COLUMN thread_data, 3 | ADD COLUMN thread_previous_id int, 4 | ADD COLUMN thread_next_id int; -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-Black.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-Bold.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-Light.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-Italic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-Regular.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-SemiBold.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-BoldItalic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-ExtraBold.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-ExtraLight.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-BlackItalic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-LightItalic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-ExtraBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-ExtraBoldItalic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /src/public/font/nunito/Nunito-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/together-support/togethernet/HEAD/src/public/font/nunito/Nunito-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /src/public/config/index.js: -------------------------------------------------------------------------------- 1 | const publicConfig = { 2 | domain: process.env.DOMAIN, 3 | defaultMode: process.env.DEFAULT_MODE || 'egalitarian', 4 | }; 5 | 6 | export default publicConfig; 7 | -------------------------------------------------------------------------------- /src/public/svg/agree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/public/svg/block.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/public/svg/disagree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/public/svg/stand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/public/js/index.js: -------------------------------------------------------------------------------- 1 | import store from '@js/store'; 2 | import Togethernet from '@js/Togethernet'; 3 | 4 | $(window).load(() => { 5 | new Togethernet().initialize(); 6 | window.debugStore = store; 7 | }); 8 | -------------------------------------------------------------------------------- /src/public/js/utils.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export const formatDateString = (date) => moment(date).format('MMMM D YYYY'); 4 | 5 | export const formatDateTimeString = (date) => 6 | moment(date).format('MMMM D YYYY, h:mm'); 7 | 8 | export const formatDateLabel = (date) => 9 | formatDateString(date).replaceAll(' ', '-'); 10 | -------------------------------------------------------------------------------- /src/public/js/api.js: -------------------------------------------------------------------------------- 1 | export const updateMessage = async ({messageId, content, order}) => { 2 | await fetch(`/archive/${messageId}`, { 3 | method: 'POST', 4 | headers: {'Content-Type': 'application/json'}, 5 | body: JSON.stringify({ 6 | content, 7 | order, 8 | }), 9 | }) 10 | .then((response) => response.text()) 11 | .then((data) => console.log(data)); 12 | }; 13 | -------------------------------------------------------------------------------- /src/db/2-3.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "messages" 2 | ( 3 | id serial, 4 | author VARCHAR(30) NOT NULL, 5 | content TEXT NOT NULL, 6 | room_id VARCHAR(30) NOT NULL, 7 | participant_names VARCHAR(30)[], 8 | participant_ids VARCHAR(30)[], 9 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 10 | message_type message_type, 11 | position int, 12 | commentable_id int, 13 | thread_previous_id int, 14 | thread_next_id int 15 | ); -------------------------------------------------------------------------------- /src/public/js/User/animation.js: -------------------------------------------------------------------------------- 1 | import { 2 | onAnimationComplete, 3 | hideEphemeralMessageText, 4 | } from '@js/Room/animation'; 5 | 6 | export const makeDraggableUser = () => { 7 | if ($('#user').hasClass('ui-draggable')) { 8 | $('#user').draggable('destroy'); 9 | } 10 | $('#user').draggable({ 11 | grid: [$('#user').outerWidth(), $('#user').outerWidth()], 12 | stop: onAnimationComplete, 13 | containment: 'parent', 14 | }); 15 | 16 | $('#user').on('dragstart', () => { 17 | hideEphemeralMessageText(); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "plugins": ["html"], 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 12, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "indent": ["error", 2], 14 | "linebreak-style": ["error", "unix"], 15 | "quotes": ["error", "single"], 16 | "semi": ["error", "always"] 17 | }, 18 | "parser": "babel-eslint", 19 | "globals": { 20 | "$": true, 21 | "process": true, 22 | "Buffer": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/public/js/PeerConnection/ensureWebRTC.js: -------------------------------------------------------------------------------- 1 | export const getBrowserRTC = () => { 2 | if (typeof window === 'undefined') return null; 3 | var wrtc = { 4 | RTCPeerConnection: 5 | window.RTCPeerConnection || 6 | window.mozRTCPeerConnection || 7 | window.webkitRTCPeerConnection, 8 | RTCSessionDescription: 9 | window.RTCSessionDescription || 10 | window.mozRTCSessionDescription || 11 | window.webkitRTCSessionDescription, 12 | RTCIceCandidate: 13 | window.RTCIceCandidate || 14 | window.mozRTCIceCandidate || 15 | window.webkitRTCIceCandidate, 16 | }; 17 | if (!wrtc.RTCPeerConnection) return null; 18 | return wrtc; 19 | }; 20 | -------------------------------------------------------------------------------- /src/server/Archiver.js: -------------------------------------------------------------------------------- 1 | import PGClient from './PGClient.js'; 2 | 3 | class Archiver { 4 | constructor() { 5 | this.archivalClient = new PGClient(); 6 | } 7 | 8 | write({resource, values, callback}) { 9 | return this.archivalClient.write({resource, values, callback}); 10 | } 11 | 12 | update({resource, id, values, callback}) { 13 | return this.archivalClient.update({resource, id, values, callback}); 14 | } 15 | 16 | readAll(resource, callback) { 17 | return this.archivalClient.readAll(resource, callback); 18 | } 19 | 20 | delete({resource, id, callback}) { 21 | return this.archivalClient.delete({resource, id, callback}); 22 | } 23 | } 24 | 25 | export default Archiver; 26 | -------------------------------------------------------------------------------- /browserify.js: -------------------------------------------------------------------------------- 1 | import browserify from 'browserify-middleware'; 2 | import babelify from 'babelify'; 3 | import publicConfig from './src/public/config/index.js'; 4 | 5 | browserify.settings({ 6 | transform: [ 7 | babelify.configure({ 8 | extensions: ['.js'], 9 | presets: [ 10 | "@babel/preset-env", 11 | "@babel/preset-react" 12 | ], 13 | plugins: [ 14 | "@babel/plugin-proposal-class-properties", 15 | "@babel/plugin-transform-runtime", 16 | ["module-resolver", { 17 | "alias": { 18 | "@js": "./src/public/js", 19 | "@public": "./src/public", 20 | } 21 | }] 22 | ] 23 | }), 24 | ['envify', publicConfig] 25 | ] 26 | }); 27 | 28 | export default browserify; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore all logfiles and tempfiles. 8 | /log/* 9 | /tmp/* 10 | !/log/.keep 11 | !/tmp/.keep 12 | 13 | # Ignore uploaded files in development. 14 | /storage/* 15 | !/storage/.keep 16 | 17 | /public/assets 18 | .byebug_history 19 | 20 | # Ignore master key for decrypting credentials and more. 21 | /config/master.key 22 | 23 | node_modules 24 | /node_modules 25 | /yarn-error.log 26 | yarn-debug.log* 27 | .yarn-integrity 28 | 29 | /db/postgres 30 | 31 | # Ignore .DS_Store 32 | .DS_Store 33 | 34 | .env -------------------------------------------------------------------------------- /src/public/css/roomForm.css: -------------------------------------------------------------------------------- 1 | #meetingMode { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | #meetingMode input { 7 | margin-right: 10px; 8 | } 9 | 10 | .facilitatorsContainer > children { 11 | margin-right: 5px; 12 | } 13 | 14 | .facilitatorsContainer { 15 | display: flex; 16 | padding: 15px 0; 17 | display: flex; 18 | } 19 | 20 | #addFacilitator { 21 | padding: 10px 5px; 22 | border: 1px solid whitesmoke; 23 | } 24 | 25 | #currentFacilitators { 26 | display: flex; 27 | flex-grow: 2; 28 | } 29 | 30 | .facilitatorOption { 31 | display: flex; 32 | padding: 10px; 33 | } 34 | 35 | .facilitatorOption div { 36 | height: 25px; 37 | width: 25px; 38 | margin-right: 5px; 39 | } 40 | 41 | .facilitatorOption span { 42 | align-self: center; 43 | } 44 | 45 | .facilitatorOption.selected { 46 | background-color: #ead1dcff; 47 | } 48 | -------------------------------------------------------------------------------- /src/public/js/RoomMembership/index.js: -------------------------------------------------------------------------------- 1 | import store from '@js/store'; 2 | 3 | class RoomMembership { 4 | constructor(roomId) { 5 | this.roomId = roomId; 6 | this.members = {}; 7 | } 8 | 9 | addMember = (member) => { 10 | const {socketId} = member; 11 | Object.values(store.get('rooms')).forEach((room) => { 12 | room.memberships.removeMember(socketId); 13 | if (!room.constructor.isEphemeral && room.editor === socketId) { 14 | room.setEditor(Object.values(room.memberships.members)[0]); 15 | } 16 | }); 17 | member.state.currentRoomId = this.roomId; 18 | this.members[socketId] = member; 19 | member.render(); 20 | member.renderParticipantAvatar(); 21 | }; 22 | 23 | renderAvatars = () => { 24 | Object.values(this.members).forEach((member) => { 25 | member.currentRoomId = this.roomId; 26 | member.render(); 27 | }); 28 | }; 29 | 30 | removeMember = (socketId) => { 31 | delete this.members[socketId]; 32 | }; 33 | 34 | isEmpty = () => { 35 | return !Object.keys(this.members).length; 36 | }; 37 | 38 | updateSelf = (membershipData) => { 39 | const {members} = membershipData; 40 | Object.keys(members).forEach((memberId) => { 41 | const member = store.getPeer(memberId); 42 | this.addMember(member); 43 | }); 44 | }; 45 | } 46 | 47 | export default RoomMembership; 48 | -------------------------------------------------------------------------------- /src/public/css/users.css: -------------------------------------------------------------------------------- 1 | .avatar, 2 | .shadow { 3 | width: var(--cell-size); 4 | height: var(--cell-size); 5 | } 6 | 7 | .avatar { 8 | text-align: center; 9 | /* Add avatar image */ 10 | /* background-image: url("https://upload.wikimedia.org/wikipedia/commons/e/e0/Alxira5_Avatar.svg"); 11 | background-size: var(--cell-size); */ 12 | } 13 | 14 | .avatar.facilitator { 15 | font-weight: 800; 16 | text-decoration: underline; 17 | color: var(--primary-font-color); 18 | } 19 | 20 | .avatar.newlyJoined { 21 | outline-color: var(--newly-joined-avatar-outline-color); 22 | outline-width: 2px; 23 | outline-style: solid; 24 | } 25 | 26 | .avatar > span { 27 | display: table-cell; 28 | width: var(--cell-size); 29 | height: var(--cell-size); 30 | vertical-align: middle; 31 | color: var(--primary-font-color); 32 | font-size: var(--default-font-size); 33 | text-shadow: 1px 1px 1px black; 34 | user-select: none; 35 | z-index: 5; 36 | } 37 | 38 | #user { 39 | display: contents; 40 | } 41 | 42 | #user .avatar, 43 | #user .shadow { 44 | grid-column-start: var(--avatar-x); 45 | grid-row-start: var(--avatar-y); 46 | margin: 0; 47 | } 48 | 49 | #user .avatar { 50 | position: absolute; 51 | z-index: 10; 52 | } 53 | 54 | #user .avatar.inConsentProcess { 55 | z-index: 20; 56 | } 57 | 58 | #user .shadow { 59 | z-index: 0; 60 | outline-color: var(--avatar-outline-color); 61 | outline-width: 1px; 62 | outline-style: solid; 63 | z-index: 4; 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "togethernet", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "repository": "https://github.com/xinemata/togethernet.git", 6 | "license": "MIT", 7 | "private": false, 8 | "scripts": { 9 | "start": "node src/index.js", 10 | "lint": "eslint src" 11 | }, 12 | "dependencies": { 13 | "@babel/core": "^7.12.3", 14 | "@babel/plugin-proposal-class-properties": "^7.12.1", 15 | "@babel/plugin-syntax-class-properties": "^7.12.1", 16 | "@babel/plugin-transform-runtime": "^7.12.1", 17 | "@babel/preset-env": "^7.12.1", 18 | "@babel/preset-react": "^7.12.1", 19 | "babel-plugin-module-resolver": "^4.1.0", 20 | "babelify": "^10.0.0", 21 | "body-parser": "^1.19.0", 22 | "browserify-middleware": "^8.1.1", 23 | "dompurify": "^2.1.1", 24 | "dotenv": "^8.2.0", 25 | "envify": "^4.1.0", 26 | "express": "^4.17.1", 27 | "file-type": "^15.0.1", 28 | "lodash": "^4.17.20", 29 | "moment": "^2.29.1", 30 | "pg": "^8.4.1", 31 | "simple-peer": "^9.7.2", 32 | "socket.io": "^2.3.0", 33 | "socket.io-client": "^2.3.1" 34 | }, 35 | "devDependencies": { 36 | "babel-eslint": "^10.1.0", 37 | "debug": "^4.2.0", 38 | "eslint": "^7.19.0", 39 | "eslint-config-standard": "^16.0.2", 40 | "eslint-plugin-html": "^6.1.1", 41 | "eslint-plugin-import": "^2.22.1", 42 | "eslint-plugin-node": "^11.1.0", 43 | "eslint-plugin-promise": "^4.2.1" 44 | }, 45 | "type": "module" 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) 2 | 3 | Copyright © 2020 Xin Xin 4 | 5 | This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. 6 | 7 | Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: 8 | 9 | 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 10 | 11 | 2. The User is one of the following: 12 | a. An individual person, laboring for themselves 13 | b. A non-profit organization 14 | c. An educational institution 15 | d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 16 | 17 | 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 18 | 19 | 4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server/PGClient.js: -------------------------------------------------------------------------------- 1 | import pg from 'pg'; 2 | import range from 'lodash/range.js'; 3 | 4 | const {Pool} = pg; 5 | 6 | class PGClient { 7 | constructor() { 8 | this.pool = new Pool({ 9 | user: process.env.PG_USER, 10 | host: process.env.PG_HOST, 11 | database: process.env.PG_DB, 12 | password: process.env.PG_PASSWORD, 13 | port: process.env.PG_PORT, 14 | }); 15 | } 16 | 17 | write({resource, values, callback}) { 18 | const keys = Object.keys(values); 19 | const query = { 20 | text: `INSERT INTO ${resource}(${keys.join(',')}) VALUES(${range( 21 | 1, 22 | keys.length + 1 23 | ).map((i) => `$${i}`)}) RETURNING *`, 24 | values: Object.values(values), 25 | }; 26 | 27 | this.pool.query(query, (error, result) => { 28 | callback(error, result); 29 | }); 30 | } 31 | 32 | update({resource, id, values, callback}) { 33 | const keys = Object.keys(values); 34 | const query = `UPDATE ${resource} SET ${keys 35 | .map((key) => `${key} = '${values[key]}'`) 36 | .join(', ')} WHERE id = ${id} RETURNING *`; 37 | 38 | this.pool.query(query, (error, result) => { 39 | callback(error, result); 40 | }); 41 | } 42 | 43 | readAll(resource, callback) { 44 | if(resource){ 45 | this.pool.query(`SELECT * FROM ${resource}`, (error, results) => { 46 | callback(results, error); 47 | }); 48 | } 49 | } 50 | 51 | delete({resource, id, callback}) { 52 | this.pool.query( 53 | `DELETE FROM ${resource} WHERE id = ${id} RETURNING *`, 54 | (error, results) => { 55 | callback({result: results.rows[0], error}); 56 | } 57 | ); 58 | } 59 | } 60 | 61 | export default PGClient; 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Togethernet 2 | 3 | Visit the [project website](https://togethernet.org/) for more info. 4 | 5 | # Local Development 6 | Prepare your local development environment using the following steps: 7 | 8 | ### Environment variables 9 | - If you need to set or overwrite any environment variables, they should be declared in a file named `.env` in the root of the togethernet directory. 10 | - Restart your app any time an environment variable is changed 11 | 12 | ### Set up the postgres database 13 | - Install [Postgress.app](https://postgresapp.com/). Open the application to start your postgres server. 14 | - [Create a development database](https://www.tutorialspoint.com/postgresql/postgresql_create_database.htm) 15 | - set the `PG_DB` environment variable to the name of the database you just created 16 | - set the `PG_USER` and `PG_PASSWORD` environment variables to the credentials of a user that has access to the database you just created 17 | - set up your database by running the migrations in `src/db`. The files are named for the order the migrations should be run. A file named with incrementing numbers represents a migration you would run to move your database forward. For example, running migration `1-2.sql` should be run first, with your database going from state 1 to state 2. A file named with the numbers in reverse represents a migration to roll back the state of your database. For example, `2-1.sql` would revert the state of your database from state 2 to state 1. 18 | 19 | ### Install Dependencies 20 | - Install [Yarn](https://classic.yarnpkg.com/en/docs/install). If you use the homebrew package manager, you can install with `brew install yarn`. 21 | - Run `yarn install` from the root togethernet directory. 22 | 23 | ### Start the Project 24 | - Start the project with `yarn start`. View the project at http://localhost:3000/. 25 | 26 | -------------------------------------------------------------------------------- /src/public/js/EphemeralMessage/sendText.js: -------------------------------------------------------------------------------- 1 | import store from '@js/store'; 2 | import EphemeralMessage from './index'; 3 | 4 | export const sendMessage = () => { 5 | const $messageInput = $('#writeMessage'); 6 | const content = $messageInput.val(); 7 | 8 | if (!content) { 9 | return; 10 | } 11 | 12 | if (store.getCurrentRoom().constructor.isEphemeral) { 13 | const gridColumnStart = $('#user .shadow').css('grid-column-start'); 14 | const gridRowStart = $('#user .shadow').css('grid-row-start'); 15 | const adjacentMessages = store.getCurrentUser().getAdjacentMessages(); 16 | 17 | if ( 18 | $( 19 | `#${ 20 | store.getCurrentUser().currentRoomId 21 | }-${gridColumnStart}-${gridRowStart}` 22 | ).length || adjacentMessages.length > 1 23 | ) { 24 | alert('move to an available spot to write the msg'); 25 | return; 26 | } 27 | 28 | const threadEntryMessageId = $('#writeMessage').attr( 29 | 'data-thread-entry-message' 30 | ); 31 | const isPinned = 32 | $('#pinMessage').hasClass('clicked') && 33 | store.getCurrentRoom().hasFacilitator(store.getCurrentUser().socketId); 34 | 35 | const ephemeralMessage = new EphemeralMessage({ 36 | content, 37 | isPinned, 38 | gridColumnStart, 39 | gridRowStart, 40 | threadEntryMessageId, 41 | ...store.getCurrentUser().getProfile(), 42 | }); 43 | 44 | store.getCurrentRoom().addEphemeralHistory(ephemeralMessage); 45 | store.sendToPeers({type: 'text', data: ephemeralMessage.messageData}); 46 | ephemeralMessage.render(); 47 | $('#pinMessage').removeClass('clicked'); 48 | } else { 49 | fetch('/archive', { 50 | method: 'POST', 51 | headers: {'Content-Type': 'application/json'}, 52 | body: JSON.stringify({ 53 | author: store.getCurrentUser().getProfile().name, 54 | content, 55 | room_id: 'archivalSpace', 56 | commentable_id: store.getCurrentRoom().isCommentingOnId, 57 | message_type: 'comment', 58 | }), 59 | }).catch((e) => console.log(e)); 60 | } 61 | 62 | $messageInput.val(''); 63 | }; 64 | -------------------------------------------------------------------------------- /src/public/css/reset.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | margin: 0; 83 | padding: 0; 84 | border: 0; 85 | font-size: 100%; 86 | font: inherit; 87 | vertical-align: baseline; 88 | box-sizing: border-box; 89 | } 90 | /* HTML5 display-role reset for older browsers */ 91 | article, 92 | aside, 93 | details, 94 | figcaption, 95 | figure, 96 | footer, 97 | header, 98 | hgroup, 99 | menu, 100 | nav, 101 | section { 102 | display: block; 103 | } 104 | body { 105 | line-height: 1; 106 | } 107 | ol, 108 | ul { 109 | list-style: none; 110 | } 111 | blockquote, 112 | q { 113 | quotes: none; 114 | } 115 | blockquote:before, 116 | blockquote:after, 117 | q:before, 118 | q:after { 119 | content: ""; 120 | content: none; 121 | } 122 | table { 123 | border-collapse: collapse; 124 | border-spacing: 0; 125 | } 126 | 127 | button { 128 | display: inline-block; 129 | border: none; 130 | margin: 0; 131 | text-decoration: none; 132 | -webkit-appearance: none; 133 | -moz-appearance: none; 134 | background-color: inherit; 135 | color: inherit; 136 | padding: 0; 137 | } 138 | 139 | select::-ms-expand { 140 | display: none; 141 | } 142 | select { 143 | appearance: none; 144 | -webkit-appearance: none; 145 | -moz-appearance: none; 146 | text-indent: 1px; 147 | } -------------------------------------------------------------------------------- /src/public/js/store/index.js: -------------------------------------------------------------------------------- 1 | import Room from '@js/Room'; 2 | import Peer from '@js/Peer'; 3 | 4 | class Store { 5 | constructor() { 6 | this.peers = {}; 7 | this.needRoomsInfo = true; 8 | this.rooms = {}; 9 | this.currentUser = null; 10 | } 11 | 12 | set(key, val) { 13 | return (this[key] = val); 14 | } 15 | 16 | get(key) { 17 | return this[key]; 18 | } 19 | 20 | addPeer = (id, peerConnection) => { 21 | const peer = new Peer(id, peerConnection); 22 | this.peers[id] = peer; 23 | return peer; 24 | }; 25 | 26 | getPeer = (id) => { 27 | return this.peers[id]; 28 | }; 29 | 30 | removePeer = (id) => { 31 | Object.values(this.rooms).forEach((room) => 32 | room.memberships.removeMember(id) 33 | ); 34 | delete this.peers[id]; 35 | }; 36 | 37 | sendToPeer = (dataChannel, {type, data}) => { 38 | if (dataChannel.readyState === 'open') { 39 | dataChannel.send( 40 | JSON.stringify({ 41 | type, 42 | data: { 43 | ...data, 44 | ...this.currentUser.getProfile(), 45 | }, 46 | }) 47 | ); 48 | } 49 | }; 50 | 51 | sendToPeers = ({type, data}) => { 52 | Object.values(this.peers).forEach((peer) => { 53 | this.sendToPeer(peer.dataChannel, {type, data}); 54 | }); 55 | }; 56 | 57 | getCurrentUser = () => { 58 | return this.currentUser; 59 | }; 60 | 61 | getCurrentRoom = () => { 62 | return this.rooms[this.currentUser.state.currentRoomId]; 63 | }; 64 | 65 | getRoom = (roomId) => { 66 | return this.rooms[roomId]; 67 | }; 68 | 69 | updateOrInitializeRoom = (roomId, options = {roomId, name: roomId}) => { 70 | let room = this.rooms[roomId]; 71 | if (room) { 72 | room.updateSelf(options); 73 | } else { 74 | const optionsClone = {...options}; 75 | delete optionsClone['ephemeralHistory']; 76 | delete optionsClone['members']; 77 | room = new Room(optionsClone); 78 | this.rooms[roomId] = room; 79 | room.initialize(); 80 | } 81 | return room; 82 | }; 83 | 84 | isMe = (id) => { 85 | return id === this.currentUser.socketId; 86 | }; 87 | } 88 | 89 | const store = new Store(); 90 | 91 | export default store; 92 | -------------------------------------------------------------------------------- /src/public/css/chat.css: -------------------------------------------------------------------------------- 1 | .chat { 2 | width: calc(var(--cell-size) * var(--cell-horizontal-num)); 3 | height: calc( 4 | var(--cell-size) * var(--cell-vertical-num) + var(--sendmessage-height) 5 | ); 6 | display: flex; 7 | flex-direction: column; 8 | z-index: 1; 9 | } 10 | 11 | .room { 12 | height: calc(var(--cell-vertical-num) * var(--cell-size)); 13 | border: var(--interface-border); 14 | } 15 | 16 | .room:focus { 17 | outline: none; 18 | border: var(--input-focus-border); 19 | } 20 | 21 | .sendMessageActions { 22 | height: var(--sendmessage-height); 23 | width: 100%; 24 | padding-top: var(--interface-vertical-margin); 25 | padding-left: var(--interface-vertical-margin); 26 | padding-bottom: var(--interface-vertical-margin); 27 | padding-right: 0; 28 | background-color: var(--primary-bg-color); 29 | border-bottom: var(--interface-border); 30 | border-right: var(--interface-border); 31 | border-left: var(--interface-border); 32 | align-items: center; 33 | display: flex; 34 | z-index: 1; 35 | } 36 | 37 | .sendMessageActions button { 38 | border-radius: 50%; 39 | width: 35px; 40 | height: 35px; 41 | background-color: var(--primary-bg-color); 42 | color: var(--primary-font-color); 43 | margin-right: 10px; 44 | } 45 | 46 | #writeMessage { 47 | width: calc( 48 | var(--cell-size) * var(--cell-horizontal-num) - var(--navbar-width) 49 | ); 50 | height: 35px; 51 | border-radius: 25px; 52 | border: var(--interface-border); 53 | padding-left: var(--interface-horizontal-margin); 54 | padding-right: var(--interface-horizontal-margin); 55 | margin-left: calc(var(--interface-horizontal-margin) / 2); 56 | background-color: var(--primary-bg-color); 57 | color: var(--primary-font-color); 58 | } 59 | 60 | #writeMessage:focus{ 61 | outline: none; 62 | border: var(--input-focus-border); 63 | } 64 | 65 | #pinMessage{ 66 | margin-left: var(--interface-horizontal-margin); 67 | } 68 | 69 | #pinMessage:hover{ 70 | opacity: 1; 71 | } 72 | 73 | #pinMessage:focus { 74 | outline: none; 75 | } 76 | 77 | #pinMessage::after, p.pinnedMessagesSummary::before { 78 | content: '✧'; 79 | opacity: var(--btn-overlay-opacity); 80 | } 81 | 82 | #pinMessage.clicked { 83 | border: var(--button-clicked-border); 84 | } 85 | 86 | #pinMessage.clicked::after { 87 | content: '✦'; 88 | } 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | import browserify from '../browserify.js'; 5 | import pick from 'lodash/pick.js'; 6 | 7 | import http from 'http'; 8 | import path, {dirname} from 'path'; 9 | import {fileURLToPath} from 'url'; 10 | 11 | import SignalingServer from './server/SignalingServer.js'; 12 | import Archiver from './server/Archiver.js'; 13 | 14 | dotenv.config({path: '.env', silent: true}); 15 | dotenv.config({path: '.dev.env', silent: true}); 16 | 17 | const app = express(); 18 | app.use(bodyParser.json()); 19 | const server = http.Server(app); 20 | const archiver = new Archiver(); 21 | 22 | const __filename = fileURLToPath(import.meta.url); 23 | const __dirname = dirname(__filename); 24 | 25 | if (process.env.BASIC_AUTH_ENABLED) { 26 | app.use((req, res, next) => { 27 | const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; 28 | const [login, password] = Buffer.from(b64auth, 'base64') 29 | .toString() 30 | .split(':'); 31 | 32 | if ( 33 | login === process.env.BASIC_AUTH_LOGIN && 34 | password === process.env.BASIC_AUTH_PASSWORD 35 | ) { 36 | return next(); 37 | } 38 | 39 | res.set('WWW-Authenticate', 'Basic realm="401"'); 40 | res.status(401).send('Authentication required.'); 41 | }); 42 | } 43 | 44 | app.post('/archive', (req, response) => { 45 | const values = pick(req.body, [ 46 | 'author', 47 | 'content', 48 | 'room_id', 49 | 'participant_ids', 50 | 'participant_names', 51 | 'message_type', 52 | 'commentable_id', 53 | 'thread_data', 54 | ]); 55 | archiver.write({ 56 | resource: 'messages', 57 | values, 58 | callback: (error, result) => { 59 | if (error) { 60 | console.log(error); 61 | } 62 | const message = result.rows[0]; 63 | response.status(200).json(message); 64 | signalingServer.alertArchivedMessage(message); 65 | }, 66 | }); 67 | }); 68 | 69 | app.get('/archive', (_, response) => { 70 | archiver.readAll('messages', (results, error) => { 71 | if (error) { 72 | console.log('error loading archive:', error.message); 73 | response.status(424).json({}); 74 | } else { 75 | console.log('GET /archive: ok'); 76 | response.status(200).json(results.rows); 77 | } 78 | }); 79 | }); 80 | 81 | app.post('/archive/:id', (req) => { 82 | const values = pick(req.body, ['content', 'order']); 83 | archiver.update({ 84 | resource: 'messages', 85 | id: req.params.id, 86 | values, 87 | callback: (error, result) => { 88 | if (error) { 89 | console.log(error); 90 | } 91 | signalingServer.alertArchivedMessageUpdated(result.rows[0]); 92 | }, 93 | }); 94 | }); 95 | 96 | app.delete('/archive/:id', (req, resp) => { 97 | archiver.delete({ 98 | resource: 'messages', 99 | id: req.params.id, 100 | callback: ({result, error}) => { 101 | if (error) { 102 | console.log('error', error); 103 | } 104 | if (result) { 105 | signalingServer.alertArchivedMessageDeleted(result); 106 | } 107 | resp.status(200); 108 | }, 109 | }); 110 | }); 111 | 112 | app.use(express.static(path.join(__dirname, '/public'))); 113 | 114 | app.get('/js/bundle.js', browserify('src/public/js/index.js')); 115 | 116 | const port = process.env.PORT || '3000'; 117 | server.listen(port, () => console.log(`server listening on ${port}`)); 118 | 119 | const signalingServer = new SignalingServer(server); 120 | signalingServer.connect(); 121 | -------------------------------------------------------------------------------- /src/public/css/navigation.css: -------------------------------------------------------------------------------- 1 | .navigation { 2 | background-color: var(--primary-bg-color); 3 | width: var(--navbar-width); 4 | height: calc( 5 | var(--cell-vertical-num) * var(--cell-size) + var(--sendmessage-height) 6 | ); 7 | border-top: var(--interface-border); 8 | border-bottom: var(--interface-border); 9 | border-left: var(--interface-border); 10 | border-right: none; 11 | display: flex; 12 | flex-direction: column; 13 | } 14 | 15 | .basicInfo { 16 | border-bottom: var(--interface-border); 17 | padding: var(--interface-horizontal-margin); 18 | } 19 | 20 | .basicInfo h1, 21 | .basicInfo h2 a { 22 | width: 100%; 23 | color: var(--primary-font-color); 24 | } 25 | 26 | .basicInfo h1 { 27 | font: var(--default-font-size) nunito-bold, monospace; 28 | padding-bottom: var(--interface-horizontal-margin); 29 | } 30 | 31 | .basicInfo h2 a { 32 | font: var(--default-font-size) nunito, monospace; 33 | text-decoration: none; 34 | } 35 | 36 | .navigation h3 { 37 | font: var(--default-font-size) nunito, monospace; 38 | width: 100%; 39 | color: var(--secondary-font-color); 40 | justify-content: space-between; 41 | align-items: center; 42 | display: flex; 43 | } 44 | 45 | .navigation button, .userInfo button { 46 | opacity: var(--btn-overlay-opacity); 47 | } 48 | 49 | .navigation button:hover, .userInfo button:hover{ 50 | opacity: 1; 51 | } 52 | 53 | #addRoom { 54 | width: 100%; 55 | justify-content: space-between; 56 | align-items: center; 57 | display: flex; 58 | flex-direction: row; 59 | } 60 | 61 | #addRoom h2{ 62 | display: flex; 63 | font: var(--default-font-size) nunito, monospace; 64 | color: var(--secondary-font-color); 65 | } 66 | 67 | #addRoom h3, 68 | #changeUserName h3 { 69 | display: flex; 70 | font: var(--btn-large-font-size) nunito, monospace; 71 | width: var(--btn-large-font-size); 72 | height: var(--btn-large-font-size); 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | .roomList-container { 78 | flex-grow: 1; 79 | overflow-y: scroll; 80 | margin: 0; 81 | padding: 0; 82 | } 83 | 84 | .roomsList { 85 | flex-direction: column; 86 | overflow-y: scroll; 87 | } 88 | 89 | .roomsList > * { 90 | padding: var(--interface-horizontal-margin); 91 | } 92 | 93 | .roomsList h2 { 94 | font-size: var(--default-font-size); 95 | } 96 | 97 | .roomLink { 98 | color: var(--primary-font-color); 99 | opacity: 1; 100 | margin: 0 2px 2px 0; 101 | width: 100%; 102 | min-height: 55px; 103 | padding-top: calc(var(--interface-horizontal-margin) / 2); 104 | padding-bottom: calc(var(--interface-horizontal-margin) / 2); 105 | display: flex; 106 | text-align: left; 107 | flex-direction: column; 108 | } 109 | 110 | .roomLink.currentRoom { 111 | background-color: var(--secondary-bg-color); 112 | } 113 | 114 | .userInfo { 115 | display: flex; 116 | flex-direction: row; 117 | width: 100%; 118 | min-height: var(--sendmessage-height); 119 | /* justify-content: space-between; */ 120 | align-items: center; 121 | padding: var(--interface-vertical-margin) var(--interface-horizontal-margin); 122 | border-top: var(--interface-border); 123 | } 124 | 125 | #changeUserAvatar { 126 | display: flex; 127 | order: 1; 128 | width: var(--default-font-size); 129 | height: var(--default-font-size); 130 | margin-right: calc(var(--interface-horizontal-margin) / 1.5); 131 | } 132 | 133 | input[type='color'], 134 | input[type='color' i] { 135 | width: inherit; 136 | height: inherit; 137 | background-color: transparent; 138 | cursor: pointer; 139 | border: none; 140 | outline: none; 141 | padding: 0; 142 | margin: 0; 143 | } 144 | 145 | .userName { 146 | display: flex; 147 | order: 2; 148 | color: var(--primary-font-color); 149 | } 150 | 151 | #changeUserName h3 { 152 | display: flex; 153 | order: 3; 154 | } 155 | -------------------------------------------------------------------------------- /src/public/js/Room/animation.js: -------------------------------------------------------------------------------- 1 | import store from '@js/store'; 2 | 3 | export const keyboardEvent = (event) => { 4 | event.preventDefault(); 5 | 6 | if (['ArrowUp', 'ArrowLeft', 'ArrowRight', 'ArrowDown'].includes(event.key)) { 7 | hideEphemeralMessageDetailsAndOverlay(); 8 | animateUser(event.key); 9 | } 10 | }; 11 | 12 | const animateUser = (eventKey) => { 13 | const currentColumnStart = parseInt( 14 | $('#user .shadow').css('grid-column-start') 15 | ); 16 | const currentRowStart = parseInt($('#user .shadow').css('grid-row-start')); 17 | let {newColumnStart, newRowStart} = animationEvents[eventKey]({ 18 | currentColumnStart, 19 | currentRowStart, 20 | }); 21 | 22 | const $shadow = $('#user .shadow'); 23 | $shadow[0].style.gridColumnStart = newColumnStart; 24 | $shadow[0].style.gridRowStart = newRowStart; 25 | 26 | $('#user .avatar').animate( 27 | { 28 | left: $shadow.position().left, 29 | top: $shadow.position().top, 30 | }, 31 | { 32 | duration: 180, 33 | complete: onAnimationComplete, 34 | } 35 | ); 36 | 37 | $('#user .shadow')[0].scrollIntoView(); 38 | }; 39 | 40 | const totalX = parseInt( 41 | getComputedStyle(document.documentElement).getPropertyValue( 42 | '--cell-horizontal-num' 43 | ) 44 | ); 45 | const totalY = parseInt( 46 | getComputedStyle(document.documentElement).getPropertyValue( 47 | '--cell-vertical-num' 48 | ) 49 | ); 50 | 51 | const moveUp = ({currentColumnStart: newColumnStart, currentRowStart}) => { 52 | const newRowStart = currentRowStart - 1 < 1 ? totalY : currentRowStart - 1; 53 | return {newColumnStart, newRowStart}; 54 | }; 55 | 56 | const moveDown = ({currentColumnStart: newColumnStart, currentRowStart}) => { 57 | const newRowStart = currentRowStart + 1 > totalY ? 1 : currentRowStart + 1; 58 | return {newColumnStart, newRowStart}; 59 | }; 60 | const moveLeft = ({currentColumnStart, currentRowStart: newRowStart}) => { 61 | const newColumnStart = 62 | currentColumnStart - 1 < 1 ? totalX : currentColumnStart - 1; 63 | return {newColumnStart, newRowStart}; 64 | }; 65 | const moveRight = ({currentColumnStart, currentRowStart: newRowStart}) => { 66 | const newColumnStart = 67 | currentColumnStart + 1 > totalX ? 1 : currentColumnStart + 1; 68 | return {newColumnStart, newRowStart}; 69 | }; 70 | 71 | const animationEvents = { 72 | ArrowUp: moveUp, 73 | ArrowLeft: moveLeft, 74 | ArrowRight: moveRight, 75 | ArrowDown: moveDown, 76 | }; 77 | 78 | export const hideEphemeralMessageDetailsAndOverlay = () => { 79 | $('.ephemeralMessageContainer').finish().fadeOut(500); 80 | $('.threadedRecordOverlay').finish().hide(); 81 | $('.threadedRecordForbiddenOverlay').finish().hide(); 82 | $('#writeMessage').finish().removeAttr('data-thread-entry-message'); 83 | }; 84 | 85 | export const onAnimationComplete = () => { 86 | showAdjacentMessages(); 87 | sendPositionToPeers(); 88 | }; 89 | 90 | export const showAdjacentMessages = () => { 91 | const adjacentMessages = store.getCurrentUser().getAdjacentMessages(); 92 | adjacentMessages.forEach((messageRecord) => 93 | $(messageRecord).trigger('adjacent') 94 | ); 95 | 96 | if (adjacentMessages.length === 1) { 97 | $('#writeMessage').trigger({ 98 | type: 'messageThread', 99 | threadPreviousMessage: adjacentMessages[0], 100 | }); 101 | 102 | $(adjacentMessages[0]).trigger('indicateThread'); 103 | } else if (adjacentMessages.length > 1) { 104 | adjacentMessages.forEach(adjacentMessageId => $(adjacentMessageId).trigger('indicateThreadForbidden')); 105 | } 106 | }; 107 | 108 | const sendPositionToPeers = () => { 109 | store.sendToPeers({ 110 | type: 'position', 111 | data: { 112 | columnStart: $('#user .shadow').css('grid-column-start'), 113 | rowStart: $('#user .shadow').css('grid-row-start'), 114 | }, 115 | }); 116 | }; 117 | -------------------------------------------------------------------------------- /src/server/SignalingServer.js: -------------------------------------------------------------------------------- 1 | import socketIO from 'socket.io'; 2 | 3 | export default class SignalingServer { 4 | constructor(server) { 5 | this.io = socketIO(server); 6 | this.connectedUsers = {}; 7 | } 8 | 9 | connect = () => { 10 | this.io.on('connection', (socket) => { 11 | // console.log('server connection initiated', new Date().toLocaleTimeString()) 12 | if ( 13 | Object.keys(this.io.sockets.connected).length > 14 | (process.env.CONNECTION_LIMIT || 10) 15 | ) { 16 | socket.disconnect(); 17 | } 18 | 19 | this.initConnections(socket); 20 | 21 | socket.on('sendOffers', this.handleSendOffers); 22 | socket.on('sendAnswer', (message) => { 23 | this.handleSendAnswer(socket, message); 24 | }); 25 | socket.on('trickleCandidate', this.handleTrickleCandidate); 26 | socket.on('disconnect', () => this.handleDisconnect(socket)); 27 | }); 28 | }; 29 | 30 | initConnections = (initiator) => { 31 | const peerIds = Object.keys(this.io.sockets.connected).filter( 32 | (socketId) => socketId !== initiator.id 33 | ); 34 | peerIds.forEach((peerId) => { 35 | this.sendConnection(initiator, { 36 | type: 'initConnections', 37 | initiator: initiator.id, 38 | peerId, 39 | }); 40 | }); 41 | }; 42 | 43 | alertArchivedMessage = (messageData) => { 44 | Object.keys(this.io.sockets.connected).forEach((socketId) => { 45 | const connection = this.io.sockets.connected[socketId]; 46 | this.sendConnection(connection, {type: 'archivedMessage', messageData}); 47 | }); 48 | }; 49 | 50 | alertArchivedMessageUpdated = (messageData) => { 51 | Object.keys(this.io.sockets.connected).forEach((socketId) => { 52 | const connection = this.io.sockets.connected[socketId]; 53 | this.sendConnection(connection, { 54 | type: 'archivedMessageUpdated', 55 | messageData, 56 | }); 57 | }); 58 | }; 59 | 60 | alertArchivedMessageDeleted = (messageData) => { 61 | Object.keys(this.io.sockets.connected).forEach((socketId) => { 62 | const connection = this.io.sockets.connected[socketId]; 63 | this.sendConnection(connection, { 64 | type: 'archivedMessageDeleted', 65 | messageData, 66 | }); 67 | }); 68 | }; 69 | 70 | handleSendOffers = ({offer, peerId, fromSocket}) => { 71 | const connection = this.io.sockets.connected[peerId]; 72 | this.sendConnection(connection, { 73 | type: 'offer', 74 | offer, 75 | offerInitiator: fromSocket, 76 | }); 77 | }; 78 | 79 | handleSendAnswer = (socket, {offerInitiator, answer}) => { 80 | const connection = this.io.sockets.connected[offerInitiator]; 81 | if (connection) { 82 | this.sendConnection(connection, { 83 | type: 'answer', 84 | answer, 85 | fromSocket: socket.id, 86 | }); 87 | } 88 | }; 89 | 90 | handleTrickleCandidate = ({fromSocket, candidate}) => { 91 | const peerIds = Object.keys(this.io.sockets.connected).filter( 92 | (socketId) => socketId !== fromSocket 93 | ); 94 | 95 | peerIds.forEach((peerId) => { 96 | const connection = this.io.sockets.connected[peerId]; 97 | this.sendConnection(connection, { 98 | type: 'candidate', 99 | candidate, 100 | fromSocket, 101 | }); 102 | }); 103 | }; 104 | 105 | handleDisconnect = ({id: leavingUser}) => { 106 | // console.log('server disconnected', new Date().toLocaleTimeString()) 107 | const peerIds = Object.keys(this.io.sockets.connected).filter( 108 | (socketId) => socketId !== leavingUser 109 | ); 110 | 111 | peerIds.forEach((peerId) => { 112 | const connection = this.io.sockets.connected[peerId]; 113 | this.sendConnection(connection, {type: 'peerLeave', leavingUser}); 114 | }); 115 | }; 116 | 117 | sendConnection = (socket, message) => { 118 | Boolean(socket) && socket.emit(message.type, message); 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /src/public/js/Togethernet/index.js: -------------------------------------------------------------------------------- 1 | import PeerConnection from '@js/PeerConnection'; 2 | import Room from '@js/Room'; 3 | import ArchivalSpace from '@js/ArchivalSpace'; 4 | import RoomForm from '@js/RoomForm'; 5 | import {sendMessage} from '@js/EphemeralMessage/sendText'; 6 | import { 7 | addSystemConfirmMessage 8 | } from '@js/Togethernet/systemMessage'; 9 | import { systemConfirmMsgEphemeralRoom } from '@js/constants.js'; 10 | import store from '@js/store'; 11 | import publicConfig from '@public/config'; 12 | import {EGALITARIAN_MODE} from '@js/constants'; 13 | import ephemeralMessageRenderer from '@js/EphemeralMessageRenderer'; 14 | 15 | class Togethernet { 16 | initialize = async () => { 17 | await this.initDefaultRooms(); 18 | this.attachUIEvents(); 19 | new RoomForm().initialize(); 20 | new PeerConnection().connect(); 21 | }; 22 | 23 | initDefaultRooms = async () => { 24 | const archivalSpace = await this.initArchivalSpace(); 25 | const defaultEphemeralRoom = await this.initDefaultEphemeralRoom(); 26 | 27 | store.rooms = { 28 | 'sitting-at-the-park': defaultEphemeralRoom, 29 | archivalSpace: archivalSpace, 30 | }; 31 | }; 32 | 33 | initArchivalSpace = async () => { 34 | const archivalSpace = new ArchivalSpace(); 35 | await archivalSpace.initialize(); 36 | return archivalSpace; 37 | }; 38 | 39 | initDefaultEphemeralRoom = () => { 40 | const defaultEphemeralRoom = new Room({ 41 | mode: publicConfig.defaultMode || EGALITARIAN_MODE, 42 | ephemeral: true, 43 | roomId: 'sitting-at-the-park', 44 | }); 45 | defaultEphemeralRoom.attachEvents(); 46 | addSystemConfirmMessage(systemConfirmMsgEphemeralRoom); 47 | return defaultEphemeralRoom; 48 | }; 49 | 50 | attachUIEvents = () => { 51 | this.handleMessageSendingEvents(); 52 | this.addKeyboardCues(); 53 | this.detectThreadStart(); 54 | this.hideInteractionButtonsOnMouseLeave(); 55 | $('#pinMessage').on('click', () => $('#pinMessage').toggleClass('clicked')); 56 | $('.pinnedMessagesSummary').on('click', () => { 57 | $('.pinnedMessages').empty(); 58 | $('.pinnedMessagesSummary i').removeClass('collapsed'); 59 | 60 | const {ephemeralHistory, roomId} = store.getCurrentRoom(); 61 | const pinnedRecords = Object.values(ephemeralHistory).filter( 62 | (record) => record.messageData.isPinned 63 | ); 64 | pinnedRecords.forEach(({messageData: {id}}) => { 65 | const $messageContent = ephemeralMessageRenderer.renderEphemeralDetails( 66 | roomId, 67 | id 68 | ); 69 | $messageContent.appendTo('.pinnedMessages'); 70 | }); 71 | }); 72 | }; 73 | 74 | handleMessageSendingEvents = () => { 75 | $('#writeMessage').on('keyup', (e) => { 76 | if (e.key === 'Enter') { 77 | e.preventDefault(); 78 | sendMessage(); 79 | } 80 | }); 81 | }; 82 | 83 | addKeyboardCues = () => { 84 | document.addEventListener('keyup', (e) => { 85 | if (e.shiftKey && e.key === ' ') { 86 | e.preventDefault(); 87 | const $visibleEphmeralRoom = $('.room:visible').get(0); 88 | if (document.activeElement.id === 'writeMessage') { 89 | $visibleEphmeralRoom && $visibleEphmeralRoom.focus(); 90 | } else if ($(document.activeElement).hasClass('room')) { 91 | $('#writeMessage').focus(); 92 | } else { 93 | $visibleEphmeralRoom && $visibleEphmeralRoom.focus(); 94 | } 95 | } 96 | 97 | if ( 98 | e.key.length === 1 && 99 | document.activeElement.id !== 'writeMessage' && 100 | !e.shiftKey 101 | ) { 102 | $('#writeMessage').delay(100).fadeOut(150).fadeIn(100); 103 | } 104 | }); 105 | }; 106 | 107 | detectThreadStart = () => { 108 | $('#writeMessage').on('messageThread', (e) => { 109 | if (e.threadPreviousMessage) { 110 | $(e.target).attr( 111 | 'data-thread-entry-message', 112 | e.threadPreviousMessage.id 113 | ); 114 | } else { 115 | $(e.target).removeAttr('data-thread-entry-message'); 116 | } 117 | }); 118 | }; 119 | 120 | hideInteractionButtonsOnMouseLeave = () => { 121 | $(document).on('mouseup', () => $('.longPressButton').hide()); 122 | }; 123 | } 124 | 125 | export default Togethernet; 126 | -------------------------------------------------------------------------------- /src/public/js/User/index.js: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | 3 | import store from '@js/store'; 4 | import compact from 'lodash/compact'; 5 | 6 | export default class User { 7 | constructor(socketId) { 8 | this.socketId = socketId; 9 | 10 | this.state = { 11 | currentRoomId: 'sitting-at-the-park', 12 | }; 13 | } 14 | 15 | initialize = async () => { 16 | store.set('currentUser', this); 17 | $('#changeUserAvatar').val(this.getRandomColor()); 18 | 19 | $('#changeUserAvatar').on('change', (e) => { 20 | const avatar = e.target.value; 21 | $('#user .avatar').css('background-color', avatar); 22 | $(`#participant-${store.getCurrentUser().socketId}`).css( 23 | 'background-color', 24 | avatar 25 | ); 26 | store.sendToPeers({type: 'profileUpdated'}); 27 | }); 28 | 29 | $('#changeUserName span').text('Anonymous'); 30 | $('#changeUserName').on('click', this.setMyUserName); 31 | await this.render(); 32 | }; 33 | 34 | $avatar = () => { 35 | if ($('#user').length === 1) { 36 | return $('#user'); 37 | } else { 38 | return this.initAvatar(); 39 | } 40 | }; 41 | 42 | initAvatar = () => { 43 | const $user = $('
'); 44 | const $shadow = $('
'); 45 | 46 | const initials = $('#changeUserName span').text().slice(0, 2); 47 | const $avatar = $( 48 | `
${initials}
` 49 | ); 50 | $avatar.css('background-color', $('#changeUserAvatar').val()); 51 | 52 | $avatar.appendTo($user); 53 | $shadow.appendTo($user); 54 | 55 | return $user; 56 | }; 57 | 58 | getProfile = () => { 59 | return { 60 | socketId: this.socketId, 61 | roomId: this.state.currentRoomId, 62 | name: $('#changeUserName').text(), 63 | avatar: $('#changeUserAvatar').val(), 64 | }; 65 | }; 66 | 67 | getRandomColor = () => { 68 | const randomColorString = Math.floor(Math.random() * 16777216).toString(16); 69 | return `#${randomColorString}${'0'.repeat( 70 | 6 - randomColorString.length 71 | )}`.substring(0, 7); 72 | }; 73 | 74 | getAdjacentMessages = () => { 75 | const gridColumnStart = parseInt( 76 | $('#user .shadow').css('grid-column-start') 77 | ); 78 | const gridRowStart = parseInt($('#user .shadow').css('grid-row-start')); 79 | 80 | return compact( 81 | [ 82 | `${gridColumnStart}-${gridRowStart + 1}`, 83 | `${gridColumnStart}-${gridRowStart - 1}`, 84 | `${gridColumnStart - 1}-${gridRowStart}`, 85 | `${gridColumnStart + 1}-${gridRowStart}`, 86 | ].map((position) => { 87 | return $(`#${this.state.currentRoomId}-${position}`)[0]; 88 | }) 89 | ); 90 | }; 91 | 92 | getAdjacentMessageIds = () => { 93 | return this.getAdjacentMessages().map((el) => el.id); 94 | }; 95 | 96 | joinedRoom = (joinedRoomId) => { 97 | store.getRoom(joinedRoomId).goToRoom(); 98 | }; 99 | 100 | setMyUserName = () => { 101 | const name = DOMPurify.sanitize( 102 | prompt('Please enter your name (max 25 characters):') 103 | ); 104 | if (name) { 105 | $('#changeUserName span').text(name.substr(0, 25)); 106 | $('#changeUserName').fitText(name.length > 19 ? 2 : 1, { 107 | minFontSize: '12px', 108 | maxFontSize: 'var(--default-font-size)', 109 | }); 110 | $('#user').find('span').text(name.substr(0, 2)); 111 | } 112 | store.sendToPeers({type: 'profileUpdated'}); 113 | }; 114 | 115 | isMe = (socketId) => { 116 | return this.socketId === socketId; 117 | }; 118 | 119 | updateState = ({currentRoomId}) => { 120 | this.state = {...this.state, currentRoomId}; 121 | }; 122 | 123 | renderParticipantAvatar = () => { 124 | const $roomLink = store.getRoom(this.state.currentRoomId).$roomLink; 125 | const $avatar = $(`#participant-${this.socketId}`).length 126 | ? $(`#participant-${this.socketId}`) 127 | : $(`
`); 128 | $avatar.css('background-color', $('#changeUserAvatar').val()); 129 | $avatar.appendTo($roomLink.find('.participantsContainer')); 130 | }; 131 | 132 | render = async () => { 133 | const room = store.getRoom(this.state.currentRoomId); 134 | const $avatar = this.$avatar(); 135 | 136 | if (room.constructor.isEphemeral) { 137 | $avatar.toggleClass('facilitator', room.hasFacilitator(this.socketId)); 138 | } 139 | $avatar.appendTo(room.$room); 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /src/public/js/Peer/index.js: -------------------------------------------------------------------------------- 1 | import store from '@js/store'; 2 | 3 | export default class Peer { 4 | constructor(socketId, peerConnection) { 5 | this.socketId = socketId; 6 | this.peerConnection = peerConnection; 7 | 8 | this.state = { 9 | name: '', 10 | avatar: '', 11 | currentRoomId: '', 12 | rowStart: '1', 13 | columnStart: '1', 14 | }; 15 | 16 | this.dataChannel = {}; 17 | } 18 | 19 | getProfile = () => { 20 | return { 21 | ...this.state, 22 | socketId: this.socketId, 23 | }; 24 | }; 25 | 26 | $avatar = () => { 27 | if ($(`#peer-${this.socketId}`).length === 1) { 28 | return $(`#peer-${this.socketId}`); 29 | } else { 30 | return this.initAvatar(); 31 | } 32 | }; 33 | 34 | initAvatar = () => { 35 | const {name, rowStart, columnStart, avatar} = this.state; 36 | const displayName = name.slice(0, 2); 37 | const $avatar = $( 38 | `
${displayName}
` 39 | ); 40 | $avatar.css({backgroundColor: avatar}); 41 | $avatar[0].style.gridColumnStart = columnStart; 42 | $avatar[0].style.gridRowStart = rowStart; 43 | 44 | $avatar.on('mousedown', () => $avatar.find('.makeFacilitator').show()); 45 | 46 | $avatar.delay(1000).animate( 47 | {outlineColor: 'transparent'}, 48 | { 49 | duration: 2000, 50 | complete: () => $avatar.removeClass('newlyJoined'), 51 | } 52 | ); 53 | 54 | return $avatar; 55 | }; 56 | 57 | getParticipantAvatarEl = () => { 58 | if ($(`#participant-${this.socketId}`).length === 1) { 59 | return $(`#participant-${this.socketId}`); 60 | } else { 61 | return this.initParticipantAvatar(); 62 | } 63 | }; 64 | 65 | makeFacilitatorButton = (onTransferFacilitator) => { 66 | const $makeFacilitatorContainer = $( 67 | '