├── 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 | ''
68 | );
69 | const $button = $('Make Facilitator ');
70 | $button.on('mouseup', onTransferFacilitator);
71 | $button.appendTo($makeFacilitatorContainer);
72 | return $makeFacilitatorContainer;
73 | };
74 |
75 | initialize = (state) => {
76 | this.state = state;
77 | };
78 |
79 | updateState = (options) => {
80 | this.state = {
81 | ...this.state,
82 | ...options,
83 | };
84 |
85 | const {name, avatar} = this.state;
86 | this.$avatar()
87 | .finish()
88 | .animate({backgroundColor: avatar})
89 | .find('span')
90 | .text(String(name).slice(0, 2));
91 | this.getParticipantAvatarEl().finish().animate({backgroundColor: avatar});
92 | };
93 |
94 | updateDataChannel = (dataChannel) => {
95 | this.dataChannel = dataChannel;
96 | };
97 |
98 | updatePosition = ({rowStart, columnStart}) => {
99 | this.state = {...this.state, rowStart, columnStart};
100 | this.$avatar()[0].style.gridColumnStart = columnStart;
101 | this.$avatar()[0].style.gridRowStart = rowStart;
102 | };
103 |
104 | initParticipantAvatar = () => {
105 | const $avatar = $(
106 | `
`
107 | );
108 | $avatar.css('background-color', this.state.avatar);
109 | return $avatar;
110 | };
111 |
112 | renderParticipantAvatar = () => {
113 | const $roomLink = store.getRoom(this.state.currentRoomId).$roomLink;
114 | this.getParticipantAvatarEl().appendTo(
115 | $roomLink.find('.participantsContainer')
116 | );
117 | };
118 |
119 | joinedRoom = (joinedRoomId) => {
120 | const $peerAvatar = $(`#peer-${this.socketId}`);
121 | const fadeIn = () => {
122 | $peerAvatar[0].style.gridColumnStart = 1;
123 | $peerAvatar[0].style.gridRowStart = 1;
124 | store.getRoom(joinedRoomId).addMember(this);
125 | };
126 |
127 | if ($peerAvatar.length) {
128 | $peerAvatar.finish().animate({opacity: 0}, {complete: fadeIn});
129 | } else {
130 | store.getRoom(joinedRoomId).addMember(this);
131 | }
132 | }
133 |
134 | render = () => {
135 | const room = store.getRoom(this.state.currentRoomId);
136 | const $avatar = this.$avatar();
137 |
138 | if (room.constructor.isEphemeral) {
139 | $avatar.css({opacity: 1});
140 | if (room.hasFacilitator(store.getCurrentUser().socketId) && !room.hasFacilitator(this.socketId)) {
141 | this.makeFacilitatorButton(room.onTransferFacilitator).appendTo($avatar);
142 | }
143 | $avatar.toggleClass('facilitator', room.hasFacilitator(this.socketId));
144 | }
145 |
146 | $avatar.appendTo(room.$room);
147 | };
148 | }
149 |
--------------------------------------------------------------------------------
/src/public/js/Togethernet/systemMessage.js:
--------------------------------------------------------------------------------
1 | import {systemNotifyMsgRevokedConsent} from '@js/constants.js';
2 |
3 | export const addSystemConfirmMessage = ({
4 | msgType = '',
5 | msgHeader = '',
6 | msgBody = '',
7 | msgFooter = '',
8 | yayText = '',
9 | nayText = '',
10 | yayBtn = 'Continue',
11 | yayBtnTitle = 'continue',
12 | nayBtn = 'Stop',
13 | nayBtnTitle = 'stop',
14 | }, ephemeralMessage) => {
15 |
16 | if (document.getElementById('systemConfirmMessage').style.display == 'none'){
17 |
18 | $('#systemConfirmMessage').find('h1').text(msgHeader);
19 | $('#systemConfirmMessage').find('p').text(msgBody);
20 | $('#systemConfirmMessage').find('h6').text(msgFooter);
21 | $('#systemConfirmMessage').find('.yay-container p').text(yayText);
22 | $('#systemConfirmMessage').find('.nay-container p').text(nayText);
23 |
24 | if (yayBtn.href) {
25 | $(`#${msgType}`).attr('title', yayBtnTitle);
26 | $(`#${msgType}`).show();
27 | } else {
28 | $('#systemConfirmMessage').find('button.yay').text(yayBtn);
29 | $('#systemConfirmMessage').find('button.yay').attr('title', yayBtnTitle);
30 | $('#systemConfirmMessage').find('button.yay').show();
31 | }
32 |
33 | if (nayBtn.href) {
34 | $(`#${msgType}`).attr('title', nayBtnTitle);
35 | $(`#${msgType}`).show();
36 | } else {
37 | $('#systemConfirmMessage').find('button.nay').text(nayBtn);
38 | $('#systemConfirmMessage').find('button.nay').attr('title', nayBtnTitle);
39 | $('#systemConfirmMessage').find('button.nay').show();
40 | }
41 |
42 | $('#systemConfirmMessage').show();
43 | }
44 |
45 | if (msgType === 'systemConfirmMsgConfirmConsentToArchive') {
46 | $('#systemConfirmMessage').find('button.yay').one('mouseup',()=> {
47 | ephemeralMessage.initiateConsentToArchiveProcess();
48 | });
49 | $('#systemConfirmMessage').find('button.nay').mouseup(clearSystemMessage);
50 | } else if (msgType === 'systemConfirmMsgConfirmRevokeConsentToArchive'){
51 | $('#systemConfirmMessage').find('button.yay').mouseup(() => {
52 | fetch(`archive/${ephemeralMessage.archivedMessageId}`, {
53 | method: 'DELETE',
54 | headers: { 'Content-Type': 'application/json' },
55 | }).catch((e) => console.log(e));
56 | addSystemNotifyMessage(systemNotifyMsgRevokedConsent);
57 | });
58 | $('#systemConfirmMessage').find('button.nay').mouseup(clearSystemMessage);
59 | }
60 | else {
61 | $('#systemConfirmMessage')
62 | .find('button.yay')
63 | .mouseup(clearSystemMessage);
64 | }
65 | };
66 |
67 | export const addSystemNotifyMessage = ({
68 | msgType = '',
69 | msgHeader = '',
70 | msgBody = '',
71 | confirmBtn = 'Continue',
72 | confirmBtnTitle = 'Continue',
73 | }) => {
74 |
75 | if (document.getElementById('systemNotifyMessage').style.display == 'none') {
76 |
77 | $('#systemNotifyMessage').find('h1').text(msgHeader);
78 | $('#systemNotifyMessage').find('p').text(msgBody);
79 |
80 | if (confirmBtn.href) {
81 | $(`#${msgType}`).attr('title', confirmBtnTitle);
82 | $(`#${msgType}`).show();
83 |
84 | $('#systemNotifyMessage')
85 | .find('button.confirm')
86 | .mouseup(clearSystemMessage);
87 | } else {
88 | $('#systemNotifyMessage').find('button.confirm').text(confirmBtn);
89 | $('#systemNotifyMessage').find('button.confirm').attr('title', confirmBtnTitle);
90 | $('#systemNotifyMessage').find('button.confirm').show();
91 |
92 | $('#systemNotifyMessage').show();
93 | }
94 |
95 | $('#systemNotifyMessage')
96 | .find('button.confirm')
97 | .mouseup('mouseup', clearSystemMessage);
98 | }
99 | };
100 |
101 | export const addSystemPopupMessage = ({
102 | msgType = '',
103 | msgBody = '',
104 | }) => {
105 |
106 | if (document.getElementById('systemPopupMessage').style.display == 'none') {
107 | $('#systemPopupMessage').find('p').text(msgBody);
108 | $('#systemPopupMessage').show();
109 | $('#systemPopupMessage')
110 | .find('button.deletePopupMessage')
111 | .mouseup('mouseup', clearSystemPopupMessage);
112 | }
113 | };
114 |
115 | export const clearSystemMessage = () => {
116 | $('#systemConfirmMessage').hide();
117 | $('#systemNotifyMessage').hide();
118 | $('.yay').hide();
119 | $('.nay').hide();
120 | $('.confirm').hide();
121 | const $visibleEphmeralRoom = $('.room:visible').get(0);
122 | $visibleEphmeralRoom && $visibleEphmeralRoom.focus();
123 | };
124 |
125 | export const clearSystemPopupMessage = () => {
126 | $('#systemPopupMessage').hide();
127 | const $visibleEphmeralRoom = $('.room:visible').get(0);
128 | $visibleEphmeralRoom && $visibleEphmeralRoom.focus();
129 | };
--------------------------------------------------------------------------------
/src/public/font/nunito/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2014 The Nunito Project Authors (contact@sansoxygen.com)
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | http://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/src/public/css/oldEphemeral.css:
--------------------------------------------------------------------------------
1 | button {
2 | cursor: pointer;
3 | }
4 |
5 | .ephemeralView {
6 | display: grid;
7 | grid-template-rows: repeat(var(--cell-vertical-num), var(--cell-size));
8 | grid-template-columns: repeat(var(--cell-horizontal-num), var(--cell-size));
9 | background-color: var(--ephemeral-bg-color);
10 | }
11 |
12 | .textRecord.ephemeral,
13 | .audioRecord.ephemeral {
14 | position: absolute;
15 | }
16 |
17 | .textRecord.inConsentProcess {
18 | z-index: 15;
19 | }
20 |
21 | .textBubble.inConsentProcess .textBubble {
22 | z-index: 25;
23 | }
24 |
25 | .avatar {
26 | box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.25);
27 | padding: 10px;
28 | }
29 |
30 | .avatar.facilitator {
31 | font-weight: 800;
32 | text-decoration: underline;
33 | }
34 |
35 | .avatar.newlyJoined {
36 | outline-color: var(--newly-joined-avatar-outline-color);
37 | outline-width: 2px;
38 | outline-style: solid;
39 | }
40 |
41 | .avatar,
42 | .shadow {
43 | width: var(--cell-size);
44 | height: var(--cell-size);
45 | }
46 |
47 | .avatar > span {
48 | color: #efefef;
49 | font-size: 1em;
50 | text-shadow: 1px 1px 1px black;
51 | user-select: none;
52 | }
53 |
54 | #user {
55 | display: contents;
56 | z-index: 10;
57 | }
58 |
59 | #user.inConsentProcess {
60 | z-index: 20;
61 | }
62 |
63 | #user .avatar,
64 | #user .shadow {
65 | grid-column-start: var(--avatar-x);
66 | grid-row-start: var(--avatar-y);
67 | margin: 0;
68 | }
69 |
70 | #user .avatar {
71 | position: absolute;
72 | }
73 |
74 | #user .shadow {
75 | z-index: 0;
76 | outline-color: var(--avatar-outline-color);
77 | outline-width: 1px;
78 | outline-style: solid;
79 | }
80 |
81 | .textBubble {
82 | position: absolute;
83 | top: -10px;
84 | left: 52px;
85 | width: auto;
86 | min-width: 100px;
87 | max-width: 300px;
88 | padding: 5px;
89 | border-radius: 5px;
90 | text-align: left;
91 | border: 1px solid transparent;
92 | background-color: #f8f8f8;
93 | z-index: 15;
94 | }
95 |
96 | .textBubble.poll {
97 | min-width: 200px;
98 | }
99 |
100 | .textBubble:after {
101 | content: "";
102 | position: absolute;
103 | left: 0;
104 | top: 65%;
105 | width: 0;
106 | height: 0;
107 | border: 8px solid transparent;
108 | border-left: 0;
109 | border-bottom: 0;
110 | margin-top: -4px;
111 | margin-left: -8px;
112 | z-index: 10;
113 | border-right-color: #f8f8f8;
114 | }
115 |
116 | .textBubble p {
117 | font: 16px nunito, monospace;
118 | text-align: left;
119 | color: #000;
120 | padding: 1px;
121 | }
122 |
123 | .textBubble {
124 | color: #000;
125 | display: flex;
126 | flex-direction: column;
127 | }
128 |
129 | .textBubble.agenda p {
130 | font-size: 14px;
131 | font-weight: 600;
132 | }
133 |
134 | .textBubbleButtons {
135 | display: flex;
136 | flex-direction: row;
137 | justify-content: flex-end;
138 | padding-bottom: 5px;
139 | }
140 |
141 | .textBubbleButtons button {
142 | margin-left: 2px;
143 | height: 15px;
144 | font-size: 10px;
145 | padding: 1px;
146 | border: 1px solid #efefef;
147 | }
148 |
149 | .textBubbleButtons button.icon {
150 | width: 15px;
151 | }
152 |
153 | #makeVote {
154 | background-color: #c4c4c4;
155 | padding: 2px 5px;
156 | border-radius: 3px;
157 | }
158 |
159 | .votingButtons {
160 | margin-top: 10px;
161 | display: flex;
162 | justify-content: space-between;
163 | }
164 |
165 | .votingButtons > button {
166 | padding: 2px;
167 | border-radius: 4px;
168 | text-transform: capitalize;
169 | font-size: 10px;
170 | background: #eeeeee;
171 | color: #919191;
172 | width: calc((100% - 10px) / 3);
173 | display: flex;
174 | flex-direction: row;
175 | justify-content: space-around;
176 | }
177 |
178 | .votingButtons img {
179 | height: 10px;
180 | width: 10px;
181 | }
182 |
183 | .votingButtons span {
184 | font-size: 10px;
185 | }
186 |
187 | .votingButtons > button.myVote {
188 | background-color: #c4c4c4;
189 | color: #000;
190 | }
191 |
192 | .longPressButton {
193 | position: absolute;
194 | top: -30px;
195 | width: 95px;
196 | left: -20px;
197 | z-index: 10;
198 | }
199 |
200 | .longPressButton > button {
201 | padding: 5px 0;
202 | border-radius: 5px;
203 | font-size: 10px;
204 | width: 95px;
205 | user-select: none;
206 | }
207 |
208 | .shortLine {
209 | position: absolute;
210 | height: 10px;
211 | width: 1px;
212 | background-color: black;
213 | left: 42.5px;
214 | bottom: -10px;
215 | }
216 |
217 | .consentToArchiveOverlay {
218 | width: 100%;
219 | height: 100%;
220 | background-color: rgba(0, 0, 0, 0.8);
221 | z-index: 9;
222 | position: absolute;
223 | }
224 |
225 | .consentIndicator.given {
226 | width: 10px;
227 | height: 10px;
228 | }
229 |
--------------------------------------------------------------------------------
/src/public/css/archival.css:
--------------------------------------------------------------------------------
1 | #archivalSpaceLink p::before {
2 | font: var(--default-font-size) nunito, monospace;
3 | content: "☰";
4 | margin-right: calc(var(--interface-horizontal-margin) / 2);
5 | }
6 |
7 | #archivalSpace {
8 | background-color: var(--primary-bg-color);
9 | display: flex;
10 | flex-direction: column;
11 | width: 100%;
12 | border-bottom: var(--interface-border);
13 | padding: 0;
14 | }
15 |
16 | #archivalMessagesContainer {
17 | width: 100%;
18 | display: flex;
19 | flex-direction: column;
20 | padding: 20px;
21 | }
22 |
23 | .dateGroup {
24 | width: 100%;
25 | display: flex;
26 | flex-direction: column;
27 | border-bottom: var(--interface-border);
28 | padding: 5%;
29 | }
30 |
31 | .roomGroup {
32 | display: flex;
33 | flex-direction: column;
34 | padding: calc(var(--interface-horizontal-margin) * 2) calc(var(--interface-horizontal-margin) * 2);
35 | color: var(--primary-font-color);
36 | line-height: 1.5;
37 | overflow-y: scroll;
38 | }
39 |
40 | .textRecord.archival {
41 | margin: 5px 0 0 5px;
42 | cursor: pointer;
43 | }
44 |
45 | .textRecord.archival.isEditing {
46 | outline: 2px solid blue;
47 | }
48 |
49 | .textRecord.archival > .consentIndicator {
50 | z-index: 6;
51 | width: 10px;
52 | height: 10px;
53 | }
54 |
55 | .textRecord.archival.comment {
56 | align-items: center;
57 | justify-content: center;
58 | margin-left: 0;
59 | }
60 |
61 | #archivalMessagesDetailsContainer {
62 | width: 100%;
63 | height: 100%;
64 | display: flex;
65 | flex-direction: column;
66 | background-color: var(--archival-bg-color);
67 | padding: calc(var(--interface-horizontal-margin) * 2)
68 | calc(var(--interface-horizontal-margin) * 2);
69 | color: var(--primary-font-color);
70 | line-height: 1.5;
71 | overflow-y: scroll;
72 | }
73 |
74 | .archivalMessagesDetails {
75 | width: 100%;
76 | padding: 0;
77 | }
78 |
79 | .archiveGroup h1,
80 | .archiveGroup h2,
81 | p.participantNames,
82 | .archivalMessagesDetails.comment {
83 | margin: var(--interface-vertical-margin);
84 | }
85 |
86 | button.deleteArchivedMessage{
87 | width: var(--btn-small-size);
88 | height: var(--btn-small-size);
89 | border-radius: var(--btn-border-radius);
90 | }
91 |
92 | .archivalMessagesDetails,
93 | .threadItem {
94 | margin: var(--interface-vertical-margin)
95 | calc(var(--interface-vertical-margin) * 4);
96 | }
97 |
98 | .archiveGroup h1 {
99 | font: var(--systemMsg-font-size) nunito-bold, monospace;
100 | }
101 |
102 | .archiveGroup h2 {
103 | font: var(--default-font-size) nunito, monospace;
104 | }
105 |
106 | p.participantNames,
107 | .archivalMessagesDetails.comment,
108 | .threadItem {
109 | color: var(--secondary-font-color);
110 | padding-left: var(--default-font-size);
111 | }
112 |
113 | .archivalMessagesDetails .author {
114 | font-style: italic;
115 | }
116 |
117 | .archivalMessagesDetails .mainContent {
118 | display: flex;
119 | flex-direction: row;
120 | flex-wrap: wrap;
121 | }
122 |
123 | .archivalMessagesDetails.comment,
124 | .threadItem {
125 | padding-left: 16px;
126 | }
127 |
128 | #displayEditorOptions {
129 | display: flex;
130 | align-items: center;
131 | }
132 |
133 | .editorAvatar {
134 | order: 1;
135 | width: var(--default-font-size);
136 | height: var(--default-font-size);
137 | margin-left: calc(var(--interface-horizontal-margin) / 1.5);
138 | }
139 |
140 | .editorName {
141 | order: 2;
142 | color: var(--primary-font-color);
143 | margin-left: calc(var(--interface-horizontal-margin) / 1.5);
144 | }
145 |
146 | .editorName::after {
147 | content: ' is editing';
148 | font-style: italic;
149 | color: var(--secondary-font-color);
150 | }
151 |
152 | #downloadArchives {
153 | width: var(--btn-large-size);
154 | height: var(--btn-large-size);
155 | border: var(--interface-border);
156 | border-radius: var(--interface-border-radius);
157 | color: var(--primary-font-color);
158 | margin-left: var(--interface-horizontal-margin);
159 | padding-top: calc(var(--btn-large-size)/6);
160 | cursor: pointer;
161 | text-align: center;
162 | opacity: var(--btn-overlay-opacity);
163 | }
164 |
165 | #downloadArchives:hover{
166 | opacity: 1;
167 | }
168 |
169 |
170 | .archivalMessagesDetails.hovered {
171 | background-color: var(--interface-highlight);
172 | cursor: pointer;
173 | }
174 |
175 | .archivalMessageActions {
176 | width: 100%;
177 | display: flex;
178 | flex-direction: row;
179 | justify-content: flex-end;
180 | }
181 |
182 | .archivalMessageActions button {
183 | width: var(--btn-large-size);
184 | height: var(--btn-large-size);
185 | font: var(--default-font-size) nunito, monospace;
186 | border-radius: var(--btn-border-radius);
187 | color: var(--primary-font-color);
188 | border: var(--interface-border);
189 | margin-top: var(--interface-horizontal-margin);
190 | margin-right: var(--interface-horizontal-margin);
191 | opacity: var(--btn-overlay-opacity);
192 | }
193 |
194 | .archivalMessageActions button:hover{
195 | opacity: 1;
196 | }
197 |
198 | #writeMessage:disabled {
199 | background-color: rgba(255, 255, 255, 0.15);
200 | }
201 |
202 | .commentArchivedMessage.clicked {
203 | background-color: whitesmoke;
204 | color: black;
205 | border: 1px solid black;
206 | }
207 |
208 | .editorInfo {
209 | position: relative;
210 | }
211 |
212 | .editorOptions {
213 | width: var(--navbar-width);
214 | position: absolute;
215 | border: var(--interface-border);
216 | bottom: 100%;
217 | left: 0;
218 | display: flex;
219 | flex-direction: column;
220 | color: var(--primary-font-color);
221 | }
222 |
223 | .editorOption {
224 | color: var(--primary-font-color);
225 | padding: var(--default-font-size);
226 | border: var(--interface-border);
227 | display: flex;
228 | flex-direction: row;
229 | align-items: center;
230 | }
231 |
--------------------------------------------------------------------------------
/src/public/css/ephemeral.css:
--------------------------------------------------------------------------------
1 | .ephemeralView {
2 | display: grid;
3 | grid-template-rows: repeat(var(--cell-vertical-num), var(--cell-size));
4 | grid-template-columns: repeat(var(--cell-horizontal-num), var(--cell-size));
5 | /* Add a background image to the ephemeral chanel */
6 | /* background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/C%C3%A9vennes_France_night_sky_with_stars_02.jpg/640px-C%C3%A9vennes_France_night_sky_with_stars_02.jpg");
7 | background-size: calc(var(--cell-horizontal-num) * var(--cell-size)) calc(var(--cell-vertical-num) * var(--cell-size)); */
8 | background-color: var(--ephemeral-bg-color);
9 | }
10 |
11 | .author {
12 | font: var(--default-font-size) nunito-bold, monospace;
13 | }
14 |
15 | .ephemeralRecord,
16 | .threadedRecordOverlay,
17 | .threadedRecordForbiddenOverlay {
18 | width: var(--cell-size);
19 | height: var(--cell-size);
20 | /* Turn message records into leaves */
21 | /* border-radius: 0 50px;*/
22 | /* Rotate message records */
23 | /* transform: rotate(10deg); */
24 | }
25 |
26 | .ephemeralRecord {
27 | display: flex;
28 | flex-wrap: wrap;
29 | position: relative;
30 | opacity: var(--ephemeral-record-opacity);
31 | /* Add message record image */
32 | /* background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Leaf_texture_in_macro_%28Unsplash%29.jpg/320px-Leaf_texture_in_macro_%28Unsplash%29.jpg");
33 | background-size: var(--cell-size); */
34 | }
35 |
36 | .threadedRecordOverlay,
37 | .threadedRecordForbiddenOverlay {
38 | position: absolute;
39 | z-index: 3;
40 | }
41 |
42 | .threadedRecordOverlay {
43 | background-color: rgba(0, 0, 255, 0.5);
44 | outline: blue solid 1px;
45 | }
46 |
47 | .threadedRecordForbiddenOverlay {
48 | background-color: rgba(255, 0, 0, 0.5);
49 | outline: red solid 1px;
50 | }
51 |
52 | .ephemeral button.roomLink p::before,
53 | .archiveGroup h2::before {
54 | font: var(--default-font-size) nunito, monospace;
55 | content: "⌗";
56 | margin-right: calc(var(--interface-horizontal-margin) / 2);
57 | }
58 |
59 | .ephemeralMessageContainer.inConsentProcess,
60 | .ephemeralRecord.inConsentProcess {
61 | z-index: 15;
62 | }
63 |
64 | .ephemeralRecord.inConsentProcess{
65 | opacity: 1;
66 | }
67 |
68 | .ephemeralMessageContainer {
69 | width: var(--ephemeral-msg-container-width);
70 | height: var(--ephemeral-msg-container-height);
71 | min-width: 200px;
72 | min-height: 100px;
73 | background-color: rgba(0, 0, 0, 0.7);
74 | position: absolute;
75 | z-index: 5;
76 | margin-top: var(--interface-horizontal-margin);
77 | margin-bottom: var(--interface-horizontal-margin);
78 | margin-left: calc(
79 | var(--cell-size) * var(--cell-horizontal-num) -
80 | var(--ephemeral-msg-container-width) - (var(--interface-vertical-margin) * 2)
81 | );
82 | color: var(--primary-font-color);
83 | font-size: var(--default-font-size);
84 | border-radius: var(--interface-border-radius);
85 | border: var(--interface-border);
86 | overflow-y: scroll;
87 | display:flex;
88 | flex-direction: column;
89 | }
90 |
91 | .pinnedMessageContainer {
92 | border-bottom: var(--interface-border);
93 | }
94 |
95 | #pinnedMessageCount {
96 | margin-left: var(--default-font-size);
97 | }
98 |
99 | .pinnedMessages,
100 | .nonPinnedMessages {
101 | overflow-y: scroll;
102 | }
103 |
104 | .ephemeralRecordDetails {
105 | display: flex;
106 | flex-direction: column;
107 | width: inherit;
108 | padding: var(--default-font-size);
109 | }
110 |
111 | .ephemeralRecordDetails:hover {
112 | background-color: var(--interface-highlight);
113 | }
114 |
115 | .ephemeralRecordDetails .heading {
116 | width: 100%;
117 | display: flex;
118 | justify-content: space-between;
119 | margin-bottom: var(--default-font-size);
120 | }
121 |
122 | .ephemeralRecordDetails .heading .messageActions button {
123 | width: calc(var(--default-font-size) * 1.5);
124 | height: calc(var(--default-font-size) * 1.5);
125 | border: var(--interface-border);
126 | border-radius: var(--interface-border-radius);
127 | margin-left: var(--interface-horizontal-margin);
128 | }
129 |
130 | .ephemeralRecordDetails .heading .messageActions button.checked {
131 | background-color: var(--btn-inverted-color);
132 | color: var(--btn-inverted-font-color);;
133 | }
134 |
135 | .ephemeralRecordDetails p {
136 | display: inline-block;
137 | width: 100%;
138 | word-break: break-word;
139 | }
140 |
141 | .votingButtonsContainer {
142 | width: 100%;
143 | }
144 |
145 | .votingButtons {
146 | width: 100%;
147 | display: flex;
148 | justify-content: space-around;
149 | }
150 |
151 | .voteOption,
152 | .feedbackOption {
153 | background-color: whitesmoke;
154 | border-radius: 5px;
155 | display: flex;
156 | flex-wrap: nowrap;
157 | color: black;
158 | width: 32%;
159 | padding: 2px 10px;
160 | justify-content: space-between;
161 | align-items: center;
162 | opacity: var(--btn-overlay-opacity);
163 | }
164 |
165 | .voteOption.myVote {
166 | opacity: 1;
167 | }
168 |
169 | .voteOption h3 {
170 | display: flex;
171 | font: var(--default-font-size) nunito, monospace;
172 | margin: 0;
173 | padding: 0;
174 | }
175 |
176 | .consentToArchiveOverlay {
177 | width: calc(var(--cell-size) * var(--cell-horizontal-num) - 4px);
178 | height: calc(var(--cell-size) * var(--cell-vertical-num) - 4px);
179 | background-color: var(--consentToArchiveOverlay-bg-color);
180 | z-index: 9;
181 | position: absolute;
182 | }
183 |
184 | .consentIndicator.given {
185 | width: calc(var(--cell-size) / 5);
186 | height: calc(var(--cell-size) / 5);
187 | z-index: 1;
188 | }
189 |
190 | .pinnedMessagesSummary {
191 | cursor: pointer;
192 | padding: var(--default-font-size);
193 | }
194 |
195 | .pinnedMessagesSummary i.collapsed {
196 | transform: rotate(-45deg);
197 | }
198 |
199 | .messageActions {
200 | display: flex;
201 | }
--------------------------------------------------------------------------------
/src/public/js/EphemeralMessageRenderer.js:
--------------------------------------------------------------------------------
1 | import store from '@js/store';
2 | import {roomModes} from '@js/constants';
3 | import isPlainObject from 'lodash/isPlainObject';
4 | import {
5 | addSystemConfirmMessage,
6 | } from '@js/Togethernet/systemMessage';
7 | import { systemConfirmMsgConfirmRevokeConsentToArchive } from '@js/constants.js';
8 | export const renderEphemeralDetails = (roomId, messageId) => {
9 | const room = store.getRoom(roomId);
10 | const message = room.ephemeralHistory[messageId];
11 |
12 | const $ephemeralRecordDetails = $(
13 | document
14 | .getElementById('ephemeralRecordDetailsTemplate')
15 | .content.cloneNode(true)
16 | );
17 | const $messageDetails = $ephemeralRecordDetails.find(
18 | '.ephemeralRecordDetails'
19 | );
20 |
21 | if (!message) {
22 | $messageDetails.text('[message removed]');
23 | } else {
24 | const myId = store.getCurrentUser().socketId;
25 |
26 | const {id, name, content, socketId, canVote, archivedMessageId, inConsentToArchiveProcess, consentToArchiveRecords = {}} = message.messageData;
27 |
28 | $messageDetails.attr('id', `ephemeralDetails-${id}`);
29 |
30 | $messageDetails.find('.author').text(name);
31 | $messageDetails.find('.content').text(content);
32 |
33 | if (store.isMe(socketId)) {
34 | const $removeMessageButton = renderCloseButton(message);
35 | $removeMessageButton.appendTo($messageDetails.find('.messageActions'));
36 | }
37 |
38 | if (room.mode === roomModes.directAction) {
39 | const $consentfulGestures = renderConsentfulGestures(message);
40 | $consentfulGestures.appendTo(
41 | $messageDetails.find('.votingButtonsContainer')
42 | );
43 | }
44 |
45 | if (
46 | room.mode === roomModes.facilitated &&
47 | room.facilitators.includes(myId) &&
48 | !canVote
49 | ) {
50 | const $makeVoteButton = renderCreatePollButton(message);
51 | $makeVoteButton.appendTo($messageDetails.find('.messageActions'));
52 | }
53 |
54 | if (room.mode === roomModes.facilitated && canVote) {
55 | const $majorityRulesButtons = renderMajorityRulesButtons(message);
56 | $majorityRulesButtons.appendTo(
57 | $messageDetails.find('.votingButtonsContainer')
58 | );
59 | }
60 |
61 | if ((!archivedMessageId || Object.keys(consentToArchiveRecords).includes(store.getCurrentUser().socketId)) && !inConsentToArchiveProcess) {
62 | const $consentToArchiveButton = renderConsentToArchiveButton(message);
63 | $consentToArchiveButton.appendTo($messageDetails.find('.messageActions'));
64 | }
65 | }
66 |
67 | return $ephemeralRecordDetails;
68 | };
69 |
70 | const renderConsentToArchiveButton = (message) => {
71 | const { archivedMessageId, consentToArchiveRecords = {} } = message.messageData;
72 | const $consentToArchiveButton = $(
73 | ' '
74 | );
75 |
76 | if (
77 | archivedMessageId &&
78 | Object.keys(consentToArchiveRecords).includes(
79 | store.getCurrentUser().socketId
80 | )
81 | ) {
82 | $consentToArchiveButton.addClass('checked');
83 | $consentToArchiveButton.on('click', () => {
84 | addSystemConfirmMessage(systemConfirmMsgConfirmRevokeConsentToArchive, message.messageData);
85 | });
86 | } else {
87 | $consentToArchiveButton.on('click', (e) => {
88 | e.stopPropagation();
89 | message.consentToArchiveButtonClicked();
90 | });
91 | }
92 |
93 | return $consentToArchiveButton;
94 | };
95 |
96 | const renderConsentfulGestures = (message) => {
97 | const {votes} = message.messageData;
98 |
99 | const $consentfulGesturesTemplate = $(
100 | document
101 | .getElementById('consentfulGesturesTemplate')
102 | .content.cloneNode(true)
103 | );
104 | if (isPlainObject(votes)) {
105 | Object.keys(votes).forEach((option) => {
106 | $consentfulGesturesTemplate
107 | .find(`.voteOption.${option} .voteCount`)
108 | .text(votes[option]);
109 | });
110 | }
111 |
112 | $consentfulGesturesTemplate.find('.voteOption').each((_, option) => {
113 | $(option).on('click', () => {
114 | $(option).toggleClass('myVote');
115 | $('.voteOption')
116 | .not(`.${$(option).data('value')}`)
117 | .removeClass('myVote');
118 | message.castVote($(option).data('value'));
119 | });
120 | });
121 |
122 | return $consentfulGesturesTemplate;
123 | };
124 |
125 | const renderCreatePollButton = (message) => {
126 | const $makeVoteButton = $(
127 | '✓ '
128 | );
129 |
130 | $makeVoteButton.on('click', (e) => {
131 | message.createPoll();
132 | e.target.closest('.makeVote').remove();
133 | });
134 |
135 | return $makeVoteButton;
136 | };
137 |
138 | const renderMajorityRulesButtons = (message) => {
139 | const {votes} = message.messageData;
140 |
141 | const $majorityRulesTemplate = $(
142 | document.getElementById('majorityRulesTemplate').content.cloneNode(true)
143 | );
144 | if (isPlainObject(votes)) {
145 | Object.keys(votes).forEach((option) => {
146 | $majorityRulesTemplate
147 | .find(`.voteOption.${option} .voteCount`)
148 | .text(votes[option]);
149 | });
150 | }
151 |
152 | $majorityRulesTemplate.find('.voteOption').each((_, option) => {
153 | $(option).on('click', () => {
154 | $(option).toggleClass('myVote');
155 | $('.voteOption')
156 | .not(`.${$(option).data('value')}`)
157 | .removeClass('myVote');
158 | message.castVote($(option).data('value'));
159 | });
160 | });
161 |
162 | return $majorityRulesTemplate;
163 | };
164 |
165 | const renderCloseButton = (message) => {
166 | const $removeMessageButton = $('✕ ');
167 | $removeMessageButton.on('click', () => {
168 | message.purgeSelf();
169 | });
170 | return $removeMessageButton;
171 | };
172 |
--------------------------------------------------------------------------------
/src/public/js/ArchivedMessage/index.js:
--------------------------------------------------------------------------------
1 | import store from '@js/store';
2 | import { formatDateTimeString } from '@js/utils';
3 | import { updateMessage } from '@js/api';
4 | import moment from 'moment';
5 |
6 | class ArchivedMessage {
7 | constructor(props) {
8 | const { messageData, index } = props;
9 | this.messageData = messageData;
10 | this.index = index;
11 | }
12 |
13 | $messageRecord = () => {
14 | const { id } = this.messageData;
15 | return $(`#archivedMessageRecord-${id}`);
16 | };
17 |
18 | toggleIsEditing = () => {
19 | const archivalSpace = store.getRoom('archivalSpace');
20 | if (archivalSpace.isEditingMessageId === this.messageData.id) {
21 | archivalSpace.isEditingMessageId = null;
22 | } else {
23 | $(
24 | `#archivedMessageRecord-${archivalSpace.isEditingMessageId}`
25 | ).removeClass('isEditing');
26 | archivalSpace.isEditingMessageId = this.messageData.id;
27 | }
28 | this.$messageRecord().toggleClass('isEditing');
29 | };
30 |
31 | renderArchivedMessage = () => {
32 | if (this.messageData.message_type === 'text_message') {
33 | return this.renderMessageDetailsForTextRecord();
34 | } else if (this.messageData.message_type === 'comment') {
35 | return this.renderMessageDetailsForComment();
36 | } else if (this.messageData.message_type === 'thread') {
37 | return this.renderMessageDetailsForThread();
38 | }
39 | };
40 |
41 | renderBaseDetails = () => {
42 | const { id } = this.messageData;
43 |
44 | const $messageDetailsTemplate = $(
45 | document
46 | .getElementById('archivalMessagesDetailsTemplate')
47 | .content.cloneNode(true)
48 | );
49 | const $messageDetails = $messageDetailsTemplate.find(
50 | '.archivalMessagesDetails'
51 | );
52 | $messageDetails.attr('id', `archivedMessageDetails-${id}`);
53 |
54 | $messageDetails
55 | .find('.deleteArchivedMessage')
56 | .on('click', this.markMessageDeleted);
57 | $messageDetails.find('.commentArchivedMessage').on('click', () => {
58 | if (store.getCurrentRoom().editor !== store.getCurrentUser().socketId) {
59 | return;
60 | }
61 |
62 | if (store.getCurrentRoom().isCommentingOnId) {
63 | $messageDetails.find('.commentArchivedMessage').removeClass('clicked');
64 | store.getCurrentRoom().isCommentingOnId = null;
65 | $('#writeMessage').attr('disabled', 'disabled');
66 | } else {
67 | $messageDetails.find('.commentArchivedMessage').addClass('clicked');
68 | store.getCurrentRoom().isCommentingOnId = id;
69 | $('#writeMessage').removeAttr('disabled');
70 | }
71 | });
72 | $messageDetails
73 | .on('mouseenter', () => {
74 | if (!store.getCurrentRoom().isCommentingOnId) {
75 | if (
76 | store.getCurrentRoom().editor === store.getCurrentUser().socketId
77 | ) {
78 | $messageDetails.find('.archivalMessageActions').show();
79 | }
80 | $messageDetails.addClass('hovered');
81 | }
82 | })
83 | .on('mouseleave', () => {
84 | if (!store.getCurrentRoom().isCommentingOnId) {
85 | $messageDetails.find('.archivalMessageActions').hide();
86 | $messageDetails.removeClass('hovered');
87 | }
88 | });
89 |
90 | return $messageDetails;
91 | };
92 |
93 | markMessageDeleted = () => {
94 | if (
95 | store.getCurrentUser().socketId === store.getRoom('archivalSpace').editor
96 | ) {
97 | const content = `message deleted by ${
98 | store.getCurrentUser().getProfile().name
99 | }. ${moment().format('MMMM D h:mm')}`;
100 | updateMessage({
101 | messageId: this.messageData.id,
102 | content,
103 | });
104 | }
105 | };
106 |
107 | renderMessageDetailsForTextRecord = () => {
108 | const { participant_names, content, author } = this.messageData;
109 |
110 | const $messageDetails = this.renderBaseDetails();
111 | $messageDetails
112 | .find('.participantNames')
113 | .text(`Consent to Archive Participants: ${participant_names.join(', ')}`);
114 | $messageDetails.find('.index').text(this.index);
115 | $messageDetails.find('.message').text(`. ${content}`);
116 | $messageDetails.find('.author').text(author);
117 | return $messageDetails;
118 | };
119 |
120 | renderMessageDetailsForComment = () => {
121 | const { author, content, created_at } = this.messageData;
122 | const $messageDetails = this.renderBaseDetails();
123 | $messageDetails.addClass('comment');
124 | $messageDetails.find('.index').text(this.index);
125 | $messageDetails
126 | .find('.message')
127 | .text(
128 | `. ${content}. Commented by ${author}. ${formatDateTimeString(
129 | created_at
130 | )}`
131 | );
132 | $messageDetails.find('.archivalMessageActions').remove();
133 | return $messageDetails;
134 | };
135 |
136 | renderMessageDetailsForThread = () => {
137 | const { author, content, thread_data } = this.messageData;
138 | const $messageDetails = this.renderBaseDetails();
139 | $messageDetails.find('.index').text(`${this.index} .`);
140 | if (thread_data && Object.keys(thread_data).length) {
141 | $messageDetails.find('.message').text('Thread: ');
142 |
143 | const threadHead = Object.keys(thread_data).find(
144 | (threadItemId) => !thread_data[threadItemId].threadPreviousMessageId
145 | );
146 | let nextMessageId = threadHead;
147 | while (nextMessageId) {
148 | const { content, name, threadNextMessageId } = thread_data[
149 | nextMessageId
150 | ];
151 | const $contentContainerClone = $(
152 | document
153 | .getElementById('archivedThreadDetailsTemplate')
154 | .content.cloneNode(true)
155 | );
156 | $contentContainerClone.find('.message').text(`${content}.`);
157 | $contentContainerClone.find('.author').text(name);
158 | $contentContainerClone.appendTo($messageDetails);
159 | nextMessageId = threadNextMessageId;
160 | }
161 | } else {
162 | $messageDetails.find('.index').text(this.index);
163 | $messageDetails.find('.message').text(`. ${content}`);
164 | $messageDetails.find('.author').text(author);
165 | }
166 | return $messageDetails;
167 | };
168 | }
169 |
170 | export default ArchivedMessage;
171 |
--------------------------------------------------------------------------------
/src/public/js/RoomForm/index.js:
--------------------------------------------------------------------------------
1 | import {roomModes} from '@js/constants';
2 | import store from '@js/store';
3 | import Room from '@js/Room';
4 | import publicConfig from '@public/config';
5 | import pull from 'lodash/pull';
6 |
7 | const defaultOptions = {
8 | mode: publicConfig.defaultMode,
9 | roomId: '',
10 | ephemeral: true,
11 | facilitators: [],
12 | };
13 |
14 | export default class RoomForm {
15 | constructor() {
16 | this.options = {...defaultOptions};
17 |
18 | Object.values(roomModes).forEach((mode) => {
19 | $('#meetingMode').append(
20 | $(`\
21 | \
22 | \
25 | ${mode} \
26 |
\
27 | `)
28 | );
29 | });
30 | }
31 |
32 | initialize = () => {
33 | $('#newRoomId').on('change', this.updateRoomId);
34 | $('input[type=radio][name=roomMode]').on('change', this.changeMeetingMode);
35 | $('#createNewRoom').on('click', this.createNewRoom);
36 | $('#backToCustomize').on('click', () => this.goToPage(1));
37 | $('#addFacilitator').on('click', () => {
38 | this.listFacilitatorOptions();
39 | this.goToPage(2);
40 | });
41 |
42 | $('.modalOverlay').on('click', () => {
43 | $('#configureRoom').hide();
44 | this.resetForm();
45 | });
46 | $('.configureRoomView').on('click', (e) => e.stopPropagation());
47 |
48 | $('#addRoom').on('click', () => {
49 | $('#configureRoom').show();
50 | });
51 | };
52 |
53 | listFacilitatorOptions = () => {
54 | $('#configureRoom-2').find('.facilitatorOption').remove();
55 | const profiles = [
56 | store.getCurrentUser().getProfile(),
57 | ...Object.values(store.get('peers')).map((peer) => peer.getProfile()),
58 | ];
59 | profiles.forEach((profile) => {
60 | this.renderFacilitatorOption({
61 | profile,
62 | onClick: (e) => this.toggleFacilitator(e, profile),
63 | selected: this.options.facilitators.includes(profile.socketId),
64 | }).insertBefore($('#configureRoom-2 .modalButtons'));
65 | });
66 | };
67 |
68 | renderFacilitatorOption = ({profile, onClick, selected}) => {
69 | const {avatar, name} = profile;
70 | const option = $(
71 | `
${name} `
72 | );
73 | if (selected) {
74 | option.addClass('selected');
75 | }
76 | option.on('click', onClick);
77 | return option;
78 | };
79 |
80 | toggleFacilitator = (e, profile) => {
81 | const {socketId} = profile;
82 | if (this.options.facilitators.includes(socketId)) {
83 | $(e.target).closest('.facilitatorOption').toggleClass('selected');
84 | pull(this.options.facilitators, socketId);
85 | $('#currentFacilitators')
86 | .find(`div[data-socketId="${socketId}"]`)
87 | .remove();
88 | } else if (this.options.facilitators.length < 3) {
89 | $(e.target).closest('.facilitatorOption').toggleClass('selected');
90 | this.renderFacilitator(profile).appendTo($('#currentFacilitators'));
91 | this.options.facilitators.push(socketId);
92 | }
93 | };
94 |
95 | renderFacilitator = ({avatar, name, socketId}) => {
96 | return $(
97 | ``
98 | );
99 | };
100 |
101 | changeMeetingMode = (e) => {
102 | this.options.mode = e.target.value;
103 | if (this.options.mode === roomModes.egalitarian) {
104 | this.clearFacilitators();
105 | $('#configureFacilitators').hide();
106 | $('#amplifyModuleInfo').hide();
107 | $('#votingModuleInfo').hide();
108 | $('#consentfulGestureInfo').hide();
109 | } else if (this.options.mode === roomModes.facilitated) {
110 | $('#currentFacilitators').html('');
111 | this.renderFacilitator(store.getCurrentUser().getProfile()).appendTo(
112 | $('#currentFacilitators')
113 | );
114 | this.options.facilitators = [store.getCurrentUser().socketId];
115 | $('#configureFacilitatorsDA').hide();
116 | $('#configureFacilitatorsFac').show();
117 | $('#configureFacilitators').show();
118 | $('#consentfulGestureInfo').hide();
119 | $('#amplifyModuleInfo').show();
120 | $('#votingModuleInfo').show();
121 | } else if (this.options.mode === roomModes.directAction) {
122 | this.clearFacilitators();
123 | $('#configureFacilitatorsFac').hide();
124 | $('#configureFacilitatorsDA').show();
125 | $('#configureFacilitators').show();
126 | $('#amplifyModuleInfo').show();
127 | $('#votingModuleInfo').hide();
128 | $('#consentfulGestureInfo').show();
129 | }
130 | };
131 |
132 | updateRoomId = (e) => {
133 | e.preventDefault();
134 | if (e.target.value.length < 26) {
135 | $(e.target).removeClass('hasError');
136 | } else {
137 | $(e.target).addClass('hasError');
138 | }
139 |
140 | this.options.roomId = e.target.value;
141 | };
142 |
143 | goToPage = (pageNumber) => {
144 | $('.configureRoomView').hide();
145 | $(`#configureRoom-${pageNumber}`).show();
146 | };
147 |
148 | createNewRoom = (e) => {
149 | e.preventDefault();
150 |
151 | if (this.validateOptions()) {
152 | const newRoom = new Room(this.options);
153 | const {roomId} = this.options;
154 |
155 | store.rooms[roomId] = newRoom;
156 | store.getCurrentUser().updateState({currentRoomId: roomId});
157 | store.sendToPeers({
158 | type: 'newRoom',
159 | data: {
160 | options: newRoom,
161 | },
162 | });
163 |
164 | newRoom.initialize();
165 | newRoom.goToRoom();
166 | $('#configureRoom').hide();
167 | this.resetForm();
168 | }
169 | };
170 |
171 | validateOptions = () => {
172 | let isValid = true;
173 | if (!this.options.roomId) {
174 | alert('please enter a room name');
175 | isValid = false;
176 | } else if (this.options.roomId.length > 25) {
177 | alert('room names must be max 25 characters');
178 | isValid = false;
179 | } else if (
180 | Object.keys(store.rooms)
181 | .map((roomId) => roomId.toLowerCase())
182 | .includes(this.options.roomId.toLowerCase().replaceAll(' ', '-'))
183 | ) {
184 | alert('room names must be unique');
185 | isValid = false;
186 | }
187 |
188 | return isValid;
189 | };
190 |
191 | resetForm = () => {
192 | this.options = {...defaultOptions};
193 | $(`#roomMode-${defaultOptions.mode}`)
194 | .prop('checked', true)
195 | .trigger('click');
196 |
197 | $('#configureFacilitators').hide();
198 | $('#newRoomId').val(defaultOptions.roomId);
199 | $('#configureRoom-2').find('.facilitatorOption').remove();
200 | this.clearFacilitators();
201 |
202 | this.goToPage(1);
203 | };
204 |
205 | clearFacilitators = () => {
206 | this.options.facilitators = [];
207 | $('.facilitatorOption').removeClass('selected');
208 | $('#currentFacilitators').find('.facilitatorOption').remove();
209 | };
210 | }
211 |
--------------------------------------------------------------------------------
/src/public/js/PeerConnection/dataReceiver.js:
--------------------------------------------------------------------------------
1 | import store from '@js/store';
2 | import { addSystemNotifyMessage } from '@js/Togethernet/systemMessage';
3 | import { systemNotifyMsgConsentToArchiveBlocked } from '@js/constants.js';
4 | import EphemeralMessage from '@js/EphemeralMessage';
5 |
6 | export const handleData = ({ event, peerId }) => {
7 | let data;
8 | try {
9 | data = JSON.parse(event.data);
10 | } catch (err) {
11 | console.log('invalid JSON');
12 | }
13 |
14 | if (data.type === 'text') {
15 | const { roomId } = data.data;
16 | const textRecord = new EphemeralMessage(data.data);
17 | store.getRoom(roomId).addEphemeralHistory(textRecord);
18 | textRecord.render();
19 | } else if (data.type === 'initPeer') {
20 | initPeer({ ...data.data });
21 | } else if (data.type === 'position') {
22 | store.getPeer(data.data.socketId).updatePosition(data.data);
23 | } else if (data.type === 'newRoom') {
24 | addNewRoom(data.data);
25 | } else if (data.type === 'joinedRoom') {
26 | const { socketId, joinedRoomId } = data.data;
27 | const peer = store.getPeer(socketId);
28 | peer && peer.joinedRoom(joinedRoomId);
29 | } else if (data.type === 'profileUpdated') {
30 | updatePeerProfile({ ...data.data });
31 | } else if (data.type === 'removeEphemeralMessage') {
32 | removeEphemeralPeerMessage(data.data);
33 | } else if (data.type === 'removeMessageInThread') {
34 | const { messageId, roomId } = data.data;
35 | const record = store.getRoom(roomId).ephemeralHistory[messageId];
36 | record.clearMessageInThread();
37 | } else if (data.type === 'requestRooms') {
38 | sendRooms(peerId);
39 | } else if (data.type === 'shareRooms') {
40 | receiveRooms(data.data);
41 | } else if (data.type === 'setAgendaHidden') {
42 | setAgendaHidden(data.data);
43 | } else if (data.type === 'pollCreated') {
44 | const { roomId, textRecordId } = data.data;
45 | const pollRecord = store.getRoom(roomId).ephemeralHistory[textRecordId];
46 | pollRecord.pollCreated();
47 | } else if (data.type === 'voteCasted') {
48 | const { roomId, textRecordId, option, socketId } = data.data;
49 | const pollRecord = store.getRoom(roomId).ephemeralHistory[textRecordId];
50 | pollRecord.voteReceived({ option, socketId });
51 | } else if (data.type === 'voteRetracted') {
52 | const { roomId, textRecordId, option, socketId } = data.data;
53 | const pollRecord = store.getRoom(roomId).ephemeralHistory[textRecordId];
54 | pollRecord.voteRetracted({ option, socketId });
55 | } else if (data.type === 'voteChanged') {
56 | const { roomId, textRecordId, option, socketId } = data.data;
57 | const pollRecord = store.getRoom(roomId).ephemeralHistory[textRecordId];
58 | pollRecord.voteChanged({ option, socketId });
59 | } else if (data.type === 'updateFacilitators') {
60 | const { roomId, facilitators } = data.data;
61 | store.getRoom(roomId).updateFacilitators(facilitators);
62 | } else if (data.type === 'initConsentToArchiveProcess') {
63 | const { roomId, messageId, name } = data.data;
64 | const room = store.getRoom(roomId);
65 | const messageRecord = room.ephemeralHistory[messageId];
66 | messageRecord.initConsentToArchiveReceived({
67 | consentToArchiveInitiator: name,
68 | });
69 | } else if (data.type === 'blockConsentToArchive') {
70 | const { roomId, messageId, name } = data.data;
71 |
72 | addSystemNotifyMessage({
73 | msgHeader: systemNotifyMsgConsentToArchiveBlocked.msgHeader,
74 | msgBody: `${name} ${systemNotifyMsgConsentToArchiveBlocked.msgBody}`,
75 | confirmText: systemNotifyMsgConsentToArchiveBlocked.confirmText,
76 | confirmBtn: systemNotifyMsgConsentToArchiveBlocked.confirmBtn,
77 | confirmBtnTitle: systemNotifyMsgConsentToArchiveBlocked.confirmBtnTitle,
78 | });
79 |
80 | const room = store.getRoom(roomId);
81 | const messageRecord = room.ephemeralHistory[messageId];
82 | messageRecord.consentToArchiveBlocked();
83 | } else if (data.type === 'giveConsentToArchive') {
84 | const { roomId, messageId, socketId } = data.data;
85 | const room = store.getRoom(roomId);
86 | const messageRecord = room.ephemeralHistory[messageId];
87 | messageRecord.consentToArchiveReceived(store.getPeer(socketId));
88 | } else if (data.type === 'messageArchived') {
89 | const { roomId, messageId, archivedMessageId } = data.data;
90 | const room = store.getRoom(roomId);
91 | const messageRecord = room.ephemeralHistory[messageId];
92 | messageRecord.messageArchived({ archivedMessageId });
93 | } else if (data.type === 'deleteRoom') {
94 | const { removedRoom } = data.data;
95 | const room = store.rooms[removedRoom];
96 | room.purgeSelf();
97 | } else if (data.type === 'editorUpdated') {
98 | const { editorId } = data.data;
99 | const archivalSpace = store.getRoom('archivalSpace');
100 | const newEditor = archivalSpace.memberships.members[editorId];
101 | archivalSpace.setEditor(newEditor);
102 | }
103 | };
104 |
105 | const updatePeerProfile = ({ socketId, name, avatar }) => {
106 | const peer = store.getPeer(socketId);
107 | peer.updateState({ name, avatar });
108 | };
109 |
110 | const sendRooms = (peerId) => {
111 | const dataChannel = store.getPeer(peerId).dataChannel;
112 | store.sendToPeer(dataChannel, {
113 | type: 'shareRooms',
114 | data: {
115 | rooms: store.get('rooms'),
116 | },
117 | });
118 | };
119 |
120 | const receiveRooms = ({ rooms }) => {
121 | Object.keys(rooms).forEach((roomId) => {
122 | store.updateOrInitializeRoom(roomId, rooms[roomId]);
123 | });
124 | };
125 |
126 | const addNewRoom = ({ options }) => {
127 | store.updateOrInitializeRoom(options.roomId, options);
128 | };
129 |
130 | const initPeer = (data) => {
131 | const { socketId, avatar, name, roomId, room, columnStart, rowStart } = data;
132 | const peer = store.getPeer(socketId);
133 | peer.updateState({
134 | avatar,
135 | name,
136 | currentRoomId: roomId,
137 | columnStart,
138 | rowStart,
139 | });
140 | store.updateOrInitializeRoom(roomId, room).addMember(peer);
141 |
142 | const newlyJoinedOutlineColor = getComputedStyle(
143 | document.documentElement
144 | ).getPropertyValue('--newly-joined-avatar-outline-color');
145 | const defaultOutlineColor = getComputedStyle(
146 | document.documentElement
147 | ).getPropertyValue('--avatar-outline-color');
148 | $('#user .shadow')
149 | .css({ outlineColor: newlyJoinedOutlineColor })
150 | .delay(2000)
151 | .animate({ outlineColor: defaultOutlineColor }, { duration: 2000 });
152 | };
153 |
154 | const removeEphemeralPeerMessage = ({ roomId, messageId }) => {
155 | $(`.ephemeralRecord#${messageId}`)
156 | .finish()
157 | .animate(
158 | { opacity: 0 },
159 | {
160 | complete: () => {
161 | $(`.ephemeralRecord#${messageId}`).remove();
162 | $(`#ephemeralDetails-${messageId}`).text('[message removed]');
163 | store.getRoom(roomId).removeEphemeralHistory(messageId);
164 | },
165 | }
166 | );
167 | };
168 |
169 | const setAgendaHidden = ({ agendaId, shouldHide }) => {
170 | if (shouldHide) {
171 | $(`#${agendaId}`).find('.textBubble').hide();
172 | } else {
173 | $(`#${agendaId}`).find('.textBubble').show();
174 | }
175 | };
176 |
--------------------------------------------------------------------------------
/src/public/js/PeerConnection/index.js:
--------------------------------------------------------------------------------
1 | import io from 'socket.io-client';
2 | import store from '@js/store';
3 | import { getBrowserRTC } from './ensureWebRTC';
4 | import { handleData } from './dataReceiver';
5 | import {
6 | addSystemNotifyMessage
7 | } from '@js/Togethernet/systemMessage';
8 | import { systemNotifyMsgDisconnect, systemNotifyMsgError } from '@js/constants.js';
9 | import User from '@js/User';
10 |
11 | export default class PeerConnection {
12 | constructor() {
13 | this._wrtc = getBrowserRTC();
14 | this.socket = io.connect();
15 | this.initiator = false;
16 | }
17 |
18 | connect = () => {
19 |
20 | this.socket.on('connect', () => {
21 | // clearSystemMessage();
22 | new User(this.socket.id).initialize();
23 | store.getCurrentRoom().goToRoom();
24 | });
25 |
26 | this.socket.on('initConnections', this.initConnections);
27 | this.socket.on('offer', this.handleReceivedOffer);
28 | this.socket.on('answer', this.handleReceivedAnswer);
29 | this.socket.on('candidate', this.addCandidate);
30 | this.socket.on('peerLeave', this.handlePeerLeaveSocket);
31 | this.socket.on(
32 | 'archivedMessage',
33 | store.getRoom('archivalSpace').appendArchivedMessage
34 | );
35 | this.socket.on(
36 | 'archivedMessageUpdated',
37 | store.getRoom('archivalSpace').archivedMessageUpdated
38 | );
39 | this.socket.on(
40 | 'archivedMessageDeleted',
41 | store.getRoom('archivalSpace').archivedMessageDeleted
42 | );
43 | this.socket.on('error', this.handleSocketError);
44 | this.socket.on('disconnect', this.handleSocketDisconnect);
45 | };
46 |
47 | initConnections = async ({ peerId }) => {
48 | const peerConnection = this.initPeerConnection(peerId, { initiator: true });
49 | // clearSystemMessage();
50 | try {
51 | const offer = await peerConnection.createOffer({
52 | offerToReceiveAudio: true,
53 | });
54 | await peerConnection.setLocalDescription(offer);
55 | this.send({ type: 'sendOffers', offer, peerId });
56 | } catch (e) {
57 | console.log('error creating offer to connect to peers', e);
58 | }
59 | };
60 |
61 | handleReceivedOffer = async ({ offer, offerInitiator }) => {
62 | try {
63 | const peerConnection = this.initPeerConnection(offerInitiator, {
64 | initiator: false,
65 | });
66 | await peerConnection.setRemoteDescription(
67 | new this._wrtc.RTCSessionDescription(offer)
68 | );
69 | const answer = await peerConnection.createAnswer();
70 | await peerConnection.setLocalDescription(answer);
71 | this.send({ type: 'sendAnswer', answer, offerInitiator });
72 | } catch (err) {
73 | console.log('error receiving offer', err);
74 | }
75 | };
76 |
77 | initPeerConnection = (peerId, { initiator }) => {
78 | const peerConnection = new this._wrtc.RTCPeerConnection({
79 | iceServers: [
80 | {
81 | urls: [
82 | 'stun:stun.l.google.com:19302',
83 | 'stun:global.stun.twilio.com:3478',
84 | ],
85 | },
86 | ],
87 | sdpSemantics: 'unified-plan',
88 | });
89 |
90 | const peer = store.addPeer(peerId, peerConnection);
91 |
92 | peerConnection.onicecandidate = (event) => {
93 | if (event.candidate) {
94 | this.send({
95 | type: 'trickleCandidate',
96 | candidate: new this._wrtc.RTCIceCandidate(event.candidate),
97 | });
98 | }
99 | };
100 |
101 | if (initiator) {
102 | const dataChannel = peerConnection.createDataChannel('tn', {
103 | reliable: true,
104 | });
105 | peer.dataChannel = this.setUpDataChannel({
106 | dataChannel,
107 | peerId,
108 | initiator,
109 | });
110 | } else {
111 | peerConnection.ondatachannel = (event) => {
112 | peer.dataChannel = this.setUpDataChannel({
113 | dataChannel: event.channel,
114 | peerId,
115 | });
116 | };
117 | }
118 |
119 | peerConnection.oniceconnectionstatechange = () => {
120 | if (
121 | peerConnection.iceConnectionState === 'failed' ||
122 | peerConnection.iceConnectionState === 'disconnected'
123 | ) {
124 | peerConnection.restartIce();
125 | } else if (peerConnection.iceConnectionState === 'connected') {
126 | // clearSystemMessage();
127 | }
128 | };
129 |
130 | peerConnection.onconnectionstatechange = () => {
131 | if (peerConnection.connectionState === 'failed') {
132 | peerConnection.restartIce();
133 | } else if (peerConnection.connectionState === 'connected') {
134 | // clearSystemMessage();
135 | }
136 | };
137 |
138 | return peerConnection;
139 | };
140 |
141 | setUpDataChannel = ({ dataChannel, peerId, initiator }) => {
142 | dataChannel.onclose = () => {
143 | this.handlePeerLeaveSocket({ leavingUser: peerId });
144 | };
145 |
146 | dataChannel.onmessage = (event) => {
147 | handleData({ event, peerId });
148 | };
149 |
150 | dataChannel.onopen = () => {
151 | // clearSystemMessage();
152 | store.sendToPeer(dataChannel, {
153 | type: 'initPeer',
154 | data: {
155 | room: store.getCurrentRoom(),
156 | columnStart: $('#user .shadow').css('grid-column-start'),
157 | rowStart: $('#user .shadow').css('grid-row-start'),
158 | },
159 | });
160 |
161 | if (initiator && store.get('needRoomsInfo')) {
162 | store.sendToPeer(dataChannel, { type: 'requestRooms' });
163 | }
164 | };
165 |
166 | dataChannel.onerror = (event) => {};
167 |
168 | return dataChannel;
169 | };
170 |
171 | handleReceivedAnswer = async ({ fromSocket, answer }) => {
172 | await store
173 | .getPeer(fromSocket)
174 | .peerConnection.setRemoteDescription(
175 | new this._wrtc.RTCSessionDescription(answer)
176 | );
177 | };
178 |
179 | addCandidate = async ({ candidate, fromSocket }) => {
180 | try {
181 | await store.getPeer(fromSocket).peerConnection.addIceCandidate(candidate);
182 | } catch (e) {
183 | console.log('error adding received ice candidate', e);
184 | }
185 | };
186 |
187 | handleSocketError = (e) => {
188 | addSystemNotifyMessage(systemNotifyMsgError);
189 | console.log('Socket connection error', e, new Date().toLocaleTimeString());
190 | };
191 |
192 | handleSocketDisconnect = (e) => {
193 | addSystemNotifyMessage(systemNotifyMsgDisconnect);
194 | console.log('Disconnected from server', e, new Date().toLocaleTimeString());
195 | $('.participant').remove();
196 | };
197 |
198 | handlePeerLeaveSocket = ({ leavingUser }) => {
199 | $(`#peer-${leavingUser}`)
200 | .finish()
201 | .animate(
202 | { opacity: 0 },
203 | {
204 | complete: () => {
205 | $(`#peer-${leavingUser}`).remove();
206 | $(`#participant-${leavingUser}`).animate(
207 | { opacity: 0 },
208 | {
209 | complete: () => $(`#participant-${leavingUser}`).remove(),
210 | }
211 | );
212 | store.removePeer(leavingUser);
213 | },
214 | }
215 | );
216 | };
217 |
218 | send = (data) => {
219 | this.socket.emit(data.type, {
220 | ...data,
221 | fromSocket: this.socket.id,
222 | });
223 | };
224 | }
225 |
--------------------------------------------------------------------------------
/src/public/js/Room/index.js:
--------------------------------------------------------------------------------
1 | import pull from 'lodash/pull';
2 | import difference from 'lodash/difference';
3 |
4 | import store from '@js/store';
5 | import { roomModes } from '@js/constants';
6 |
7 | import { keyboardEvent, hideEphemeralMessageDetailsAndOverlay } from './animation';
8 | import { addSystemMessage } from '@js/Togethernet/systemMessage';
9 | import EphemeralMessage from '@js/EphemeralMessage';
10 | import RoomMembership from '@js/RoomMembership';
11 |
12 | export default class Room {
13 | static isEphemeral = true;
14 |
15 | constructor (options) {
16 | this.mode = options.mode;
17 | this.roomId = options.roomId.replaceAll(' ', '-');
18 | this.ephemeral = options.ephemeral;
19 | this.facilitators = options.facilitators || [];
20 | this.$room = $(`#${this.roomId}`);
21 | this.$roomLink = $(`#${this.roomId}Link`);
22 | this.memberships = new RoomMembership(this.roomId);
23 |
24 | this.inConsentToArchiveProcess = false;
25 |
26 | this.ephemeralHistory = { ...this.createMessageRecords(options.ephemeralHistory) };
27 | }
28 |
29 | initialize = () => {
30 | this.render();
31 | this.attachEvents();
32 | };
33 |
34 | render = () => {
35 | this.renderMenuButton();
36 | this.renderSpace();
37 | };
38 |
39 | renderMenuButton = () => {
40 | const $roomLink = $(' ');
41 | const $roomTitle = $('
');
42 | $roomTitle.text(this.roomId);
43 | $roomTitle.appendTo($roomLink);
44 |
45 | if (this.facilitators.includes(store.getCurrentUser().socketId)) {
46 | this.renderRemoveRoomButton().appendTo($roomTitle);
47 | }
48 |
49 | const $participantsContainer = $('
');
50 | $participantsContainer.appendTo($roomLink);
51 |
52 | $roomLink.appendTo($('.roomsList.ephemeral'));
53 | this.$roomLink = $roomLink;
54 | };
55 |
56 | renderRemoveRoomButton = () => {
57 | const $removeRoomButton = $('x ');
58 | $removeRoomButton.on('click', () => {
59 | if (this.facilitators.includes(store.getCurrentUser().socketId)) {
60 | this.purgeSelf();
61 | store.sendToPeers({
62 | type: 'deleteRoom',
63 | data: { removedRoom: this.roomId },
64 | });
65 | }
66 | });
67 |
68 | return $removeRoomButton;
69 | };
70 |
71 | purgeSelf = () => {
72 | Object.values(this.memberships.members).forEach(member => {
73 | member.joinedRoom('sitting-at-the-park');
74 | });
75 |
76 | this.$roomLink.remove();
77 | this.$room.remove();
78 | delete store.rooms[this.roomId];
79 | };
80 |
81 | renderSpace = () => {
82 | const $room = $(`
83 | `);
94 | $room.insertBefore('.sendMessageActions');
95 | this.$room = $room;
96 | };
97 |
98 | attachEvents = () => {
99 | this.$roomLink.on('click', this.goToRoom);
100 | this.$room.on('hideRoom', this.hideRoom);
101 | this.$room.on('keydown', keyboardEvent);
102 | };
103 |
104 | goToRoom = () => {
105 | $('.userInfo.ephemeral').show();
106 | $('.userInfo.editorInfo').hide();
107 | $('#archivalSpace').hide();
108 | $('#downloadArchives').hide();
109 | $('.ephemeralMessageContainer').finish().hide();
110 | $('.roomLink').removeClass('currentRoom');
111 | this.$roomLink.addClass('currentRoom');
112 | $('.room').each((_, el) => $(el).trigger('hideRoom'));
113 | this.updateMessageTypes();
114 | this.addMember(store.getCurrentUser());
115 | this.showRoom();
116 | $('#writeMessage').removeAttr('disabled');
117 | $('#writeMessage').attr('placeholder', 'Type your message here');
118 | $('#writeMessage').val('');
119 | hideEphemeralMessageDetailsAndOverlay();
120 |
121 | store.sendToPeers({
122 | type: 'joinedRoom',
123 | data: {
124 | joinedRoomId: this.roomId
125 | }
126 | });
127 | };
128 |
129 | addMember = (user) => {
130 | this.memberships.addMember(user);
131 | };
132 |
133 | showRoom = () => {
134 | store.getCurrentUser().updateState({ currentRoomId: this.roomId });
135 | this.$room.show();
136 | this.$room.focus();
137 |
138 | if (this.facilitators.includes(store.getCurrentUser().socketId)) {
139 | $('#pinMessage').show();
140 | } else {
141 | $('#pinMessage').hide();
142 | }
143 |
144 | this.memberships.renderAvatars();
145 |
146 | this.renderHistory();
147 | };
148 |
149 | hasFeature = (feature) => {
150 | if (feature === 'facilitators') {
151 | return this.mode === roomModes.directAction || this.mode === roomModes.facilitated;
152 | }
153 | };
154 |
155 | hasFacilitator = (socketId) => {
156 | return this.facilitators.includes(socketId);
157 | };
158 |
159 | onTransferFacilitator = (e) => {
160 | const newFacilitators = [...this.facilitators];
161 | pull(newFacilitators, store.getCurrentUser().socketId);
162 |
163 | const $peerAvatar = $(e.target).closest('.avatar');
164 | const peerId = $peerAvatar.attr('id').split('peer-')[1];
165 | newFacilitators.push(peerId);
166 |
167 | store.sendToPeers({
168 | type: 'updateFacilitators',
169 | data: {
170 | roomId: this.roomId,
171 | facilitators: newFacilitators,
172 | }
173 | });
174 |
175 | this.updateFacilitators(newFacilitators);
176 | };
177 |
178 | updateFacilitators = (currentFacilitators) => {
179 | const currentUser = store.getCurrentUser();
180 | const newFacilitators = difference(currentFacilitators, this.facilitators);
181 |
182 | newFacilitators.forEach(facilitatorId => {
183 | const facilitator = currentUser.isMe(facilitatorId) ? currentUser : store.getPeer(facilitatorId);
184 | const name = facilitator.getProfile().name;
185 | addSystemMessage(`${name} stepped in as the new facilitator`);
186 | });
187 |
188 | this.facilitators = currentFacilitators;
189 | this.updateCloseButtons();
190 | this.updateMessageTypes();
191 | this.memberships.renderAvatars();
192 | };
193 |
194 | renderHistory = () => {
195 | if (this.ephemeral) {
196 | Object.values(this.ephemeralHistory).forEach((messageRecord) => messageRecord.render());
197 | }
198 | this.setPinnedMessagesCount();
199 | };
200 |
201 | setPinnedMessagesCount = () => {
202 | const pinnedMessagesCount = Object.values(this.ephemeralHistory).filter(record => record.messageData.isPinned).length;
203 | $('#pinnedMessageCount').text(pinnedMessagesCount);
204 | };
205 |
206 | addEphemeralHistory = (textRecord) => {
207 | const { id, isPinned, threadPreviousMessageId } = textRecord.messageData;
208 | this.ephemeralHistory[id] = textRecord;
209 | if (isPinned) {
210 | this.setPinnedMessagesCount();
211 | }
212 |
213 | if (threadPreviousMessageId) {
214 | const previousMessage = this.ephemeralHistory[threadPreviousMessageId];
215 | previousMessage.messageData.threadNextMessageId = id;
216 | }
217 | return this.ephemeralHistory[id];
218 | };
219 |
220 | removeEphemeralHistory = (messageId) => {
221 | delete this.ephemeralHistory[messageId];
222 | };
223 |
224 | hideRoom = () => {
225 | this.$room.hide();
226 | $('#user').remove();
227 | };
228 |
229 | updateSelf = ({ mode, ephemeral, roomId, ephemeralHistory }) => {
230 | this.mode = mode;
231 | this.ephemeral = ephemeral;
232 | this.roomId = roomId;
233 | this.updateEphemeralHistory(ephemeralHistory);
234 | };
235 |
236 | updateEphemeralHistory = (ephemeralHistoryData = {}) => {
237 | this.ephemeralHistory = { ...this.ephemeralHistory, ...this.createMessageRecords(ephemeralHistoryData) };
238 | this.renderHistory();
239 | };
240 |
241 | createMessageRecords = (ephemeralHistoryData = {}) => {
242 | let ephemeralHistory = {};
243 | Object.values(ephemeralHistoryData).forEach(({ messageData }) => {
244 | const newMessageRecord = new EphemeralMessage(messageData);
245 | ephemeralHistory[newMessageRecord.messageData.id] = newMessageRecord;
246 | });
247 | return ephemeralHistory;
248 | };
249 |
250 | updateCloseButtons = () => {
251 | if (this.facilitators.includes(store.getCurrentUser().socketId)) {
252 | this.renderRemoveRoomButton().appendTo(this.$roomLink.find('p'));
253 | } else {
254 | this.$roomLink.find('.removeRoom').remove();
255 | }
256 | };
257 |
258 | updateMessageTypes = () => {
259 | $('#messageType').removeAttr('data-thread-entry-message');
260 | if (this.hasFacilitator(store.getCurrentUser().socketId)) {
261 | $('#pinMessage').show();
262 | }
263 | };
264 | }
--------------------------------------------------------------------------------
/src/public/js/constants.js:
--------------------------------------------------------------------------------
1 | export const EGALITARIAN_MODE = 'Egalitarian (default)';
2 | export const DIRECT_ACTION_MODE = 'Feedback';
3 | export const FACILITATED_MODE = 'Facilitated';
4 |
5 | export const roomModes = {
6 | egalitarian: EGALITARIAN_MODE,
7 | directAction: DIRECT_ACTION_MODE,
8 | facilitated: FACILITATED_MODE,
9 | };
10 |
11 | export const systemConfirmMsgEphemeralRoom = {
12 | msgType: 'systemConfirmMsgEphemeralRoom',
13 | msgHeader: 'Privacy Scenario: sitting-at-the-park',
14 | msgBody:
15 | 'You and a friend are sitting in your usual corner of the park on a picnic blanket speaking among each other.',
16 | msgFooter:
17 | 'In the ephemeral channel your conversations are private and encrypted by default. However the underlying webRTC protocol does leave some exposure. If you\'d like to learn more about this, we invite you to dig deeper.',
18 | yayText: 'Sounds good, I\'m enthusiastic to participate!',
19 | nayText: 'I\'d like to review how the ephemeral channel works.',
20 | yayBtn: 'Continue',
21 | yayBtnTitle: 'continue',
22 | nayBtn: { href: true },
23 | nayBtnTitle:
24 | 'open external link to read documentation on the ephemeral channel'
25 | };
26 |
27 | export const systemConfirmMsgArchivalRoom = {
28 | msgType: 'systemConfirmMsgArchivalRoom',
29 | msgHeader: 'Privacy Scenario: posting-on-a-bulletin-board',
30 | msgBody:
31 | 'You’ve posted a flyer on the bulletin board on your campus. Day in and day out, friends, acquaintances, and strangers pass by and pause to take a look at what you’ve posted. Some of them may even take a photo of the flyer on their phone to show it to other people.',
32 | msgFooter:
33 | 'In the archive channel your messages are stored on a centralized server and anyone who has access to this page can read, edit, and potentially republish your messages to other locations.',
34 | yayText: 'Sounds good, I\'m enthusiastic to participate!',
35 | nayText: 'I\'d like to review how the archival channel works.',
36 | yayBtn: 'Continue',
37 | yayBtnTitle: 'continue',
38 | nayBtn: {href: true},
39 | nayBtnTitle:
40 | 'open external link to read documentation on the archival channel'
41 | };
42 |
43 | export const systemConfirmMsgConfirmConsentToArchive = {
44 | msgType: 'systemConfirmMsgConfirmConsentToArchive',
45 | msgHeader: 'Consent to Archive',
46 | msgBody:
47 | 'Consent to archive is a feature that publishes a single message or a thread to the archival channel. When the consent to archive process is initiated, all activities in the space are paused and participants are asked to give their consent for the selected message to be archived.',
48 | msgFooter: 'This feature requires consent from each participant. The message will not be archived if a participant decides to stop the process. Once a message has been archived, participants also have the ability to revoke their consent and bring the archived message back to the ephemeral channel. ',
49 | yayText: 'I would like to ask for everyone\'s consent to archive the selected message.',
50 | nayText: 'I would prefer not to start this process.',
51 | yayBtn: 'Initiate Consent to Archive',
52 | yayBtnTitle: 'initiate consent to archive',
53 | nayBtn: 'Return to the Channel',
54 | nayBtnTitle: 'return to the channel'
55 | };
56 |
57 | export const systemConfirmMsgInitiateConsentToArchiveProcess = {
58 | msgType: 'systemConfirmMsgInitiateConsentToArchiveProcess',
59 | msgHeader: 'Requested Consent to Archive',
60 | msgBody: 'You have just initiated the consent to archive process.',
61 | msgFooter:
62 | 'Consent to archive is a feature that publishes a chosen message or thread to the database. The consent of each participant, including the requester, is required in order for this message to be archived.',
63 | yayText: 'I understand, and I\'m enthusiastic to participate!',
64 | nayText: 'Wait. I\'d like to learn more before participating.',
65 | yayBtn: 'Continue',
66 | yayBtnTitle: 'continue to the next step',
67 | nayBtn: { href: true },
68 | nayBtnTitle: 'open external link to dig deeper on consent to archive'
69 | };
70 |
71 | export const systemConfirmMsgConsentToArchive = {
72 | msgType: 'systemConfirmMsgConsentToArchive',
73 | msgHeader: 'Consent to Archive Requested',
74 | msgBody: 'has just initiated the consent to archive process.',
75 | msgFooter:
76 | 'Consent to archive is a feature that publishes a chosen message or thread to the database. The consent of each participant is required in order for this message to be archived.',
77 | yayText: 'I understand the process and feel comfortable moving forward.',
78 | nayText: 'I do not fully understand and would like to learn more before participating.',
79 | yayBtn: 'Continue',
80 | yayBtnTitle: 'continue to the next step',
81 | nayBtn: { href: true },
82 | nayBtnTitle: 'open external link to dig deeper on consent to archive'
83 | };
84 |
85 | export const systemConfirmMsgConfirmRevokeConsentToArchive = {
86 | msgType: 'systemConfirmMsgConfirmRevokeConsentToArchive',
87 | msgHeader: 'Revoke Consent to Archive',
88 | msgBody: 'Revoking your consent will reverse the consent to archive process and bring this message from the archival channel back to the ephemeral channel. It is recommended that you discuss with your group before proceeding.',
89 | msgFooter: 'The selected message will be deleted from the database if you revoke your consent. However, this doesn\'t prevent participants from archiving the message again during the remaining session.',
90 | yayText: 'I understand and would like to revoke my consent.',
91 | nayText: 'I feel comfortable with this message remaining in the archive.',
92 | yayBtn: 'Revoke Consent',
93 | yayBtnTitle: 'revoke consent',
94 | nayBtn: 'Return to Channel',
95 | nayBtnTitle: 'return to channel'
96 | };
97 |
98 | export const systemNotifyMsgDisconnect = {
99 | msgType: 'systemNotifyMsgDisconnect',
100 | msgHeader: 'Lost Connection',
101 | msgBody:
102 | 'You have been disconnected. Please refresh the page to reconnect. Messages you have posted prior to disconnection are still visible to participants in the space. Your messages will only be removed once everyone closes their browsers. ',
103 | confirmBtn: {href: true},
104 | confirmBtnTitle:
105 | 'reload the page'
106 | };
107 |
108 | export const systemNotifyMsgError = {
109 | msgType: 'systemNotifyMsgError',
110 | msgHeader: 'Error',
111 | msgBody:
112 | 'Oops, there has been an error. Please refresh the page to reconnect.',
113 | confirmBtn: { href: true },
114 | confirmBtnTitle:
115 | 'reload the page'
116 | };
117 |
118 | export const systemNotifyNewFacilitator = {
119 | msgType: 'systemNotifyNewFacilitator',
120 | msgHeader: 'Facilitator Assigned',
121 | msgBody:
122 | 'has been assigned as the new facilitator.',
123 | confirmBtn: 'Continue',
124 | confirmBtnTitle:
125 | 'continue'
126 | };
127 |
128 | export const systemNotifyMsgRevokedConsent = {
129 | msgType: 'systemNotifyMsgRevokedConsent',
130 | msgHeader: 'Revoked Consent',
131 | msgBody:
132 | 'You have revoked your consent to archive and the selected message has been removed from the database.',
133 | confirmBtn: 'Return to Channel',
134 | confirmBtnTitle:
135 | 'return to channel'
136 | };
137 |
138 | export const systemNotifyMsgGiveConsentToArchive = {
139 | msgType: 'systemNotifyMsgGiveConsentToArchive',
140 | msgHeader: 'Consent Received',
141 | msgBody:
142 | 'Your consent to archive the message has been received. However, if there is another participant who would prefer not to give their consent, the consent to archive process will stop and the message won\'t be archived.',
143 | confirmBtn: 'Continue',
144 | confirmBtnTitle:
145 | 'continue to the next step to wait for everyone else to give their consent'
146 | };
147 |
148 | export const systemNotifyMsgBlockConsentToArchive = {
149 | msgType: 'systemNotifyMsgBlockConsentToArchive',
150 | msgHeader: 'Consent to Archive Stopped',
151 | msgBody: 'You have stopped the consent to archive process. It is encouraged that you discuss the rationales for whether a message should be archived with your group.',
152 | confirmBtn: 'Return to Channel',
153 | confirmBtnTitle: 'return to the channel'
154 | };
155 |
156 | export const systemNotifyMsgConsentToArchiveBlocked = {
157 | msgType: 'systemNotifyMsgConsentToArchiveBlocked',
158 | msgHeader: 'Consent to Archive Stopped',
159 | msgBody: 'has stopped the consent to archive process. It is encouraged that you discuss the rationales for whether a message should be archived with your group.',
160 | confirmBtn: 'Return to Channel',
161 | confirmBtnTitle: 'return to the channel'
162 | };
163 |
164 | export const systemPopupMsgConsentToArchive = {
165 | msgType: 'systemPopupMsgConsentToArchive',
166 | msgBody: 'Review the highlighted message to decide if you\'d like for it to be archived. Once you have come to a decision, enter – "S" to stop the process, "Y" to give your consent.'
167 | };
168 |
--------------------------------------------------------------------------------
/src/public/css/style.css:
--------------------------------------------------------------------------------
1 | /* Import and name font files */
2 | @font-face {
3 | font-family: nunito;
4 | src: url(../font/nunito/Nunito-Regular.ttf);
5 | }
6 |
7 | @font-face {
8 | font-family: nunito-bold;
9 | src: url(../font/nunito/Nunito-Bold.ttf);
10 | font-weight: bold;
11 | }
12 |
13 | /* Set global CSS variables */
14 | :root {
15 | --default-font-size: 18px;
16 | --systemMsg-header-font-size: 24px;
17 | --systemMsg-font-size: 20px;
18 | --font-to-margin-width-ratio: 35;
19 | --body-bg-color: black;
20 | --primary-bg-color: rgba(75, 0, 130, 0.3);
21 | --secondary-bg-color: #282828;
22 | --ephemeral-bg-color: Peru;
23 | --archival-bg-color: midnightblue;
24 | --primary-font-color: white;
25 | --secondary-font-color: darkSalmon;
26 | --interface-gray: Grey;
27 | --interface-border-width: 1px;
28 | --interface-border: var(--interface-border-width) solid dimgrey;
29 | --interface-horizontal-margin: 1vw;
30 | --interface-vertical-margin: 1vh;
31 | --interface-highlight: #282828;
32 | --interface-border-radius: 5px;
33 | --ephemeral-msg-container-width: 25vw;
34 | --ephemeral-msg-container-height: 60%;
35 | --button-border: var(--interface-border-width) solid dimgrey;
36 | --button-clicked-border: var(--interface-border-width) solid LightGray;
37 | --newly-joined-avatar-outline-color: DeepSkyBlue;
38 | --overlay-color: black;
39 | --overlay-opacity: 0.5;
40 | --consentToArchiveOverlay-bg-color:rgb(160, 82, 45, 0.8);
41 | --ephemeral-record-opacity: 0.6;
42 | --systemMsg-bg-color: black;
43 | --systemMsg-overlay-opacity: 1;
44 | --navbar-width: 20vw;
45 | --btn-large-font-size: 30px;
46 | --btn-large-size: 35px;
47 | --btn-small-font-size: 15px;
48 | --btn-small-size: 22px;
49 | --btn-border-radius: 50%;
50 | --btn-overlay-opacity: 0.8;
51 | --btn-inverted-color: whitesmoke;
52 | --btn-inverted-font-color: black;
53 | --input-focus-border: 2px solid blue;
54 | --avatar-outline-color: white;
55 | --sendmessage-height: 10vh;
56 | /* Modify the variables below with caution. The default CSS grid is 30 rows x 17 cols. The default starting position for the user avatar is x = 1, y = 1 */
57 | --cell-horizontal-num: 30;
58 | --cell-vertical-num: 17;
59 | --cell-size: 5vh;
60 | --avatar-x: 1;
61 | --avatar-y: 1;
62 | }
63 |
64 | body,
65 | p,
66 | button p,
67 | .btn,
68 | .roomLink,
69 | .configureRoomView,
70 | .modalContent p,
71 | #changeUserName,
72 | #writeMessage,
73 | #_privateSendBtn,
74 | #messageType,
75 | #systemConfirmMessage span,
76 | .textBubble p {
77 | font: var(--default-font-size) nunito, monospace;
78 | }
79 |
80 | body {
81 | background-color: var(--body-bg-color);
82 | padding: 2.5vh;
83 | }
84 |
85 | .container {
86 | position: relative;
87 | margin: auto;
88 | display: flex;
89 | flex-direction: row;
90 | height: calc(
91 | var(--cell-size) * var(--cell-vertical-num) + var(--sendmessage-height)
92 | );
93 | width: calc(
94 | var(--navbar-width) + var(--cell-size) * var(--cell-horizontal-num)
95 | );
96 | }
97 | button,
98 | a.nay,
99 | a.yay,
100 | a.confirm{
101 | cursor: pointer;
102 | }
103 |
104 | a.nay,
105 | a.yay,
106 | a.confirm {
107 | background-color: transparent;
108 | opacity: 1;
109 | }
110 |
111 | button > span,
112 | a.nay:hover,
113 | a.yay:hover,
114 | a.confirm {
115 | opacity: 1;
116 | }
117 |
118 | #messages {
119 | list-style-type: none;
120 | margin: 0;
121 | padding: 0;
122 | }
123 |
124 | #messages li {
125 | padding: 5px 10px;
126 | }
127 |
128 | #messages li:nth-child(odd) {
129 | background: #eee;
130 | }
131 |
132 | .col {
133 | display: flex;
134 | flex-direction: column;
135 | z-index: 1;
136 | }
137 |
138 | .btn {
139 | margin: 0;
140 | height: 50px;
141 | border: 0.5px solid gray;
142 | background-color: #efefef;
143 | }
144 |
145 | .participantsContainer {
146 | display: flex;
147 | height: 10px;
148 | margin-top: 5px;
149 | }
150 |
151 | .participant {
152 | height: 10px;
153 | width: 10px;
154 | margin-right: 4px;
155 | }
156 |
157 | .icon {
158 | font-size: var(--default-font-size);
159 | }
160 |
161 | .btn:hover {
162 | cursor: pointer;
163 | }
164 |
165 | input[type='color']::-webkit-color-swatch-wrapper {
166 | padding: 0;
167 | }
168 |
169 | input[type='color']::-webkit-color-swatch {
170 | border: none;
171 | }
172 |
173 | input[type='color'] {
174 | -webkit-appearance: none;
175 | border: none;
176 | }
177 |
178 | .hidden {
179 | display: none;
180 | }
181 |
182 | .modalOverlay {
183 | display: none;
184 | position: fixed;
185 | z-index: 100;
186 | left: 0;
187 | top: 0;
188 | width: 100%;
189 | height: 100%;
190 | overflow: scroll;
191 | background-color: var(--systemMsg-bg-color);
192 | opacity: var(--systemMsg-overlay-opacity);
193 | }
194 |
195 | .modalContent {
196 | margin: auto;
197 | width: calc(var(--cell-size) * var(--cell-horizontal-num));
198 | }
199 |
200 | .modalContent.long {
201 | padding: 5% 0;
202 | }
203 |
204 | .modalContent.short {
205 | height: calc(var(--cell-size) * var(--cell-vertical-num));
206 | }
207 |
208 | .configureRoomView {
209 | padding: 55px;
210 | background-color: rgba(0, 0, 0, 0.8);
211 | line-height: 2;
212 | color: white;
213 | }
214 |
215 | #systemConfirmMessage > .modalContent,
216 | #systemNotifyMessage > .modalContent {
217 | display: flex;
218 | flex-direction: column;
219 | justify-content: center;
220 | }
221 |
222 | #systemConfirmMessage h1,
223 | #systemConfirmMessage p,
224 | #systemConfirmMessage h6,
225 | #systemConfirmMessage .button-container,
226 | #systemNotifyMessage h1,
227 | #systemNotifyMessage p,
228 | #systemNotifyMessage .button-container {
229 | margin: auto;
230 | width: calc(var(--default-font-size) * var(--font-to-margin-width-ratio));
231 | padding-top: 3%;
232 | }
233 |
234 | #systemConfirmMessage h1,
235 | #systemNotifyMessage h1 {
236 | font-size: var(--systemMsg-header-font-size);
237 | color: var(--primary-font-color);
238 | }
239 |
240 | #systemConfirmMessage h1,
241 | #systemNotifyMessage h1 {
242 | font-family: nunito-bold, monospace;
243 | }
244 |
245 | #systemConfirmMessage p,
246 | #systemNotifyMessage p,
247 | #systemPopupMessage p,
248 | .confirm-container p {
249 | font-size: var(--systemMsg-header-font-size) nunito, monospace;
250 | color: var(--primary-font-color);
251 | }
252 |
253 | #systemConfirmMessage h6 {
254 | font: var(--default-font-size) nunito, monospace;
255 | color: var(--secondary-font-color);
256 | }
257 |
258 | .deletePopupMessage{
259 | align-self: flex-end;
260 | font-size: var(--btn-small-font-size);
261 | color: var(--btn-inverted-color);
262 | padding: var(--interface-vertical-margin);
263 | }
264 |
265 | #systemConfirmMessage .yay-container,
266 | #systemConfirmMessage .nay-container {
267 | display: block;
268 | float: left;
269 | width: 48%;
270 | margin-top: 2%;
271 | margin-bottom: 2%;
272 | padding: var(--default-font-size);
273 | border: var(--interface-border);
274 | }
275 |
276 | #systemConfirmMessage .yay-container {
277 | float: right;
278 | }
279 |
280 | .confirm-container {
281 | width: 100%;
282 | }
283 |
284 | #systemConfirmMessage .yay-container p,
285 | #systemConfirmMessage .nay-container p,
286 | #systemNotifyMessage .confirm-container p {
287 | width: 100%;
288 | min-height: calc(var(--default-font-size) * 5);
289 | margin: auto;
290 | }
291 |
292 | #systemConfirmMessage button.yay,
293 | #systemConfirmMessage button.nay,
294 | #systemConfirmMessage a.yay,
295 | #systemConfirmMessage a.nay,
296 | #systemNotifyMessage button.confirm,
297 | #systemNotifyMessage a.confirm {
298 | display: block;
299 | width: 90%;
300 | text-align: center;
301 | color: var(--primary-font-color);
302 | text-decoration: none;
303 | font: var(--systemMsg-font-size) nunito, monospace;
304 | border: var(--button-border);
305 | border-radius: var(--interface-border-radius);
306 | margin: auto;
307 | padding: var(--interface-vertical-margin);
308 | }
309 |
310 | #systemPopupMessage{
311 | display: flex;
312 | flex-direction: column;
313 | width: var(--ephemeral-msg-container-width);
314 | height: 23%;;
315 | min-width: 300px;
316 | min-height: 100px;
317 | background-color: black;
318 | position: absolute;
319 | z-index: 5;
320 | top: calc(var(--cell-size) * var(--cell-vertical-num) - 23% - var(--interface-horizontal-margin));
321 | margin-left: calc(
322 | var(--cell-size) * var(--cell-horizontal-num) - var(--ephemeral-msg-container-width) - (var(--interface-vertical-margin)*2)
323 | );
324 | padding: var(--interface-horizontal-margin);
325 | color: var(--primary-font-color);
326 | font-size: var(--default-font-size);
327 | border-radius: var(--interface-border-radius);
328 | border: var(--interface-border);
329 | overflow-y: scroll;
330 | }
331 |
332 | .modalContent .modalButtons {
333 | display: flex;
334 | justify-content: flex-end;
335 | }
336 |
337 | .modalContent .modalButtons button {
338 | padding: 5px 10px;
339 | margin-left: 20px;
340 | background-color: #919191;
341 | color: #ffffff;
342 | }
343 |
344 | .modalContent .section {
345 | padding: 0 0 10px 0;
346 | }
347 |
348 | #messageType {
349 | background-color: #efefef;
350 | padding: 0 10px;
351 | border-top: 0px solid transparent;
352 | }
353 |
354 | .removeRoom {
355 | margin-left: var(--interface-horizontal-margin);
356 | font: var(--btn-small-font-size) nunito, monospace;
357 | width: var(--btn-small-size);
358 | height: var(--btn-small-size);
359 | border-radius: var(--btn-border-radius);
360 | border: var(--interface-border);
361 | }
362 |
363 | #newRoomId {
364 | font-size: var(--default-font-size);
365 | width: 300px;
366 | padding: 5px 8px;
367 | margin-top: 10px;
368 | border-radius: 5px;
369 | }
370 |
371 | .hasError {
372 | border: 2px solid red;
373 | }
374 |
375 | .hasError:focus {
376 | outline: 2 px solid red;
377 | }
378 |
379 | p.inline{
380 | display: inline;
381 | }
382 |
383 | p.keyIcon{
384 | font: var(--default-font-size) nunito-bold, monospace;
385 | display: inline-block;
386 | padding:0 5px;
387 | margin:0;
388 | margin-block-start: 0;
389 | margin-block-end: 0;
390 | line-height: 0;
391 | border: var(--interface-border);
392 | border-radius: var(--interface-border-radius);
393 | }
394 |
395 | #systemConfirmMessage,
396 | #systemNotifyMessage,
397 | #systemPopupMessage {
398 | z-index: 100;
399 | }
400 |
--------------------------------------------------------------------------------
/src/public/js/ArchivalSpace/index.js:
--------------------------------------------------------------------------------
1 | import RoomMembership from '@js/RoomMembership';
2 | import ArchivedMessage from '@js/ArchivedMessage';
3 | import store from '@js/store';
4 | import { addSystemConfirmMessage } from '@js/Togethernet/systemMessage';
5 | import { systemConfirmMsgArchivalRoom } from '@js/constants';
6 | import groupBy from 'lodash/groupBy';
7 | import orderBy from 'lodash/orderBy';
8 | import filter from 'lodash/filter';
9 | import moment from 'moment';
10 | import { formatDateString, formatDateLabel } from '@js/utils';
11 |
12 | class ArchivalSpace {
13 | static isEphemeral = false;
14 |
15 | constructor() {
16 | this.messageRecords = [];
17 | this.memberships = new RoomMembership('archivalSpace');
18 |
19 | this.editor = null;
20 | this.isCommentingOnId = null;
21 |
22 | this.$roomLink = $('#archivalSpaceLink');
23 | }
24 |
25 | initialize = () => {
26 | this.fetchArchivedMessages().then((error) => {
27 | if (!error) {
28 | $('.roomsList.archival').removeClass('hidden');
29 | this.attachEvents();
30 | this.render();
31 | }
32 | });
33 | };
34 |
35 | attachEvents = () => {
36 | this.$roomLink.on('click', this.goToRoom);
37 | $('#deleteArchivedMessage').on('click', this.markMessageDeleted);
38 | $('#downloadArchives').on('click', this.downloadArchives);
39 | $('#displayEditorOptions').on('click', this.toggleEditorOptionsVisible);
40 | };
41 |
42 | goToRoom = () => {
43 | addSystemConfirmMessage(systemConfirmMsgArchivalRoom);
44 |
45 | $('.userInfo.ephemeral').hide();
46 | $('.userInfo.editorInfo').show();
47 | $('#writeMessage').attr('disabled', 'disabled');
48 | $('#writeMessage').attr('placeholder', 'Comment on an archived message');
49 | $('.ephemeralView').hide();
50 | $('.ephemeralMessageContainer').hide();
51 | $('#pinMessage').hide();
52 | $('.roomLink').removeClass('currentRoom');
53 | this.$roomLink.addClass('currentRoom');
54 | this.addMember(store.getCurrentUser());
55 |
56 | $('#archivalSpace').show();
57 | $('#downloadArchives').show();
58 |
59 | store.sendToPeers({
60 | type: 'joinedRoom',
61 | data: {
62 | joinedRoomId: 'archivalSpace',
63 | }
64 | });
65 | };
66 |
67 | downloadArchives = () => {
68 | const archiveContent = $('#archivalMessagesDetailsContainer').html();
69 | $('#downloadArchives').attr({
70 | download: `togethernetArchives-${moment().format('MM dd YY')}.html`,
71 | href:
72 | 'data:text/plain;charset=utf-8,' + encodeURIComponent(archiveContent),
73 | });
74 | };
75 |
76 | addMember = (user) => {
77 | if (this.memberships.isEmpty() || !this.editor) {
78 | this.setEditor(user);
79 | }
80 | this.memberships.addMember(user);
81 | };
82 |
83 | iAmEditor = () => {
84 | return this.editor === store.getCurrentUser().socketId;
85 | };
86 |
87 | setEditor = (user) => {
88 | if (!user) {
89 | return;
90 | }
91 | const editorProfile = user.getProfile();
92 | this.editor = editorProfile.socketId;
93 | $('#displayEditorOptions').find('.editorName').text(editorProfile.name);
94 | $('#displayEditorOptions')
95 | .find('.editorAvatar')
96 | .css({ backgroundColor: editorProfile.avatar });
97 | };
98 |
99 | toggleEditorOptionsVisible = () => {
100 | if ($('.editorOptions').is(':visible')) {
101 | $('.editorOptions').hide();
102 | } else {
103 | $('.editorOptions').empty();
104 | Object.values(this.memberships.members).forEach((user) => {
105 | const { avatar, socketId, name } = user.getProfile();
106 | const $editorOption = $(
107 | `${name}
`
108 | );
109 | $editorOption.find('.editorAvatar').css({ backgroundColor: avatar });
110 | $editorOption.on('click', () => {
111 | this.setEditor(user);
112 | store.sendToPeers({
113 | type: 'editorUpdated',
114 | data: { editorId: socketId },
115 | });
116 | $('.editorOptions').hide();
117 | });
118 | $editorOption.appendTo($('.editorOptions'));
119 | });
120 | $('.editorOptions').show();
121 | }
122 | };
123 |
124 | fetchArchivedMessages = async () => {
125 | try {
126 | const response = await fetch('/archive', {
127 | method: 'GET',
128 | headers: { 'Content-Type': 'application/json' },
129 | });
130 |
131 | if (response.ok) {
132 | const messageRecords = await response.json();
133 | this.messageRecords = messageRecords;
134 | } else {
135 | return new Error('Failed to fetch');
136 | }
137 | } catch (error) {
138 | return error;
139 | }
140 | };
141 |
142 | archivedMessageUpdated = ({ messageData }) => {
143 | const { id, content } = messageData;
144 | $(`#archivedMessageDetails-${id}`).find('.content').text(content);
145 | if (this.isEditingMessageId === id) {
146 | $(`#archivedMessageRecord-${id}`).removeClass('isEditing');
147 | this.isEditingMessageId = null;
148 | }
149 | };
150 |
151 | archivedMessageDeleted = ({ messageData }) => {
152 | const { id, room_id } = messageData;
153 | $(`#archivedMessageDetails-${id}`).remove();
154 |
155 | const room = store.getRoom(room_id);
156 | if (room) {
157 | const ephemeralMessage = Object.values(room.ephemeralHistory).find(
158 | (message) => message.messageData.archivedMessageId === id
159 | );
160 | if (ephemeralMessage) {
161 | ephemeralMessage.consentToArchiveBlocked();
162 | }
163 | }
164 | };
165 |
166 | appendArchivedMessage = ({ messageData }) => {
167 | const { message_type, commentable_id, room_id, created_at } = messageData;
168 | const message = new ArchivedMessage({
169 | messageData,
170 | index: this.getIndex(messageData),
171 | });
172 | const $details = message.renderArchivedMessage();
173 | if (!$(`#dateGroup-${formatDateLabel(created_at)}`).length) {
174 | this.appendDateGroup(created_at);
175 | }
176 |
177 | if (
178 | !$(`#dateGroup-${formatDateLabel(created_at)} .roomGroup-${room_id}`)
179 | .length
180 | ) {
181 | this.appendRoomGroup(room_id, created_at);
182 | }
183 |
184 | if (['text_message', 'thread'].includes(message_type)) {
185 | $details.appendTo(
186 | $(`#dateGroup-${formatDateLabel(created_at)} .roomGroup-${room_id}`)
187 | );
188 | } else if (message_type === 'comment') {
189 | $details.appendTo($(`#archivedMessageDetails-${commentable_id}`));
190 | }
191 | };
192 |
193 | getIndex = (messageData) => {
194 | if (['text_message', 'thread'].includes(messageData.message_type)) {
195 | return this.getIndexForMessage(messageData);
196 | } else {
197 | return this.getIndexForMessageForComment(messageData);
198 | }
199 | };
200 |
201 | getIndexForMessage = (messageData) => {
202 | const { room_id, created_at } = messageData;
203 | const dateString = formatDateLabel(created_at);
204 | const $dateRoomGroup = $(`#dateGroup-${dateString} .roomGroup-${room_id}`);
205 | return $dateRoomGroup.find('.archivalMessagesDetails').length + 1;
206 | };
207 |
208 | getIndexForMessageForComment = (messageData) => {
209 | const { commentable_id } = messageData;
210 | const prefix = $(`#archivedMessageDetails-${commentable_id}`)
211 | .find('.index')
212 | .first()
213 | .text();
214 | const suffix =
215 | $(`#archivedMessageDetails-${commentable_id}`).find('.comment').length +
216 | 1;
217 | return `${prefix}.${suffix}`;
218 | };
219 |
220 | appendDateGroup = (date) => {
221 | const $dateGroupForMessageRecords = $(
222 | `
`
225 | );
226 | const $dateHeading = $(' ');
227 | $dateHeading.text(formatDateString(date));
228 | $dateHeading.appendTo($dateGroupForMessageRecords);
229 | $dateGroupForMessageRecords.appendTo(
230 | $('#archivalMessagesDetailsContainer')
231 | );
232 | };
233 |
234 | appendRoomGroup = (room, date) => {
235 | const $roomGroup = $(
236 | `
`
237 | );
238 | const $roomHeading = $(' ');
239 | $roomHeading.text(room);
240 | $roomHeading.appendTo($roomGroup);
241 |
242 | $roomGroup.appendTo($(`#dateGroup-${formatDateLabel(date)}`));
243 | };
244 |
245 | updateSelf = (data) => {
246 | const { editor, memberships } = data;
247 | this.editor = editor;
248 | Object.keys(memberships.members).forEach((memberId) => {
249 | this.addMember(store.getPeer(memberId));
250 | });
251 | };
252 |
253 | groupedTextMessages = () => {
254 | const groupedMessages = {};
255 | const textMessages = filter(this.messageRecords, (record) =>
256 | ['text_message', 'thread'].includes(record.message_type)
257 | );
258 | const dateGroupedMessages = groupBy(textMessages, (messageRecord) => {
259 | return formatDateString(messageRecord.created_at);
260 | });
261 |
262 | orderBy(Object.keys(dateGroupedMessages), (date) => moment(date), [
263 | 'desc',
264 | 'asc',
265 | ]).forEach((date) => {
266 | groupedMessages[date] = groupBy(dateGroupedMessages[date], 'room_id');
267 | });
268 |
269 | return groupedMessages;
270 | };
271 |
272 | renderArchivedMessages = () => {
273 | const groupedMessages = this.groupedTextMessages();
274 | Object.keys(groupedMessages).forEach((date) => {
275 | this.appendDateGroup(date);
276 | const messagesByDate = groupedMessages[date];
277 | Object.keys(messagesByDate).forEach((roomId) => {
278 | this.appendRoomGroup(roomId, date);
279 | messagesByDate[roomId].forEach((messageData) => {
280 | this.appendArchivedMessage({ messageData });
281 | });
282 | });
283 | });
284 | };
285 |
286 | renderComments = () => {
287 | const comments = filter(
288 | this.messageRecords,
289 | (record) => record.message_type === 'comment'
290 | );
291 | comments.forEach((messageData) =>
292 | this.appendArchivedMessage({ messageData })
293 | );
294 | };
295 |
296 | render = () => {
297 | this.renderArchivedMessages();
298 | this.renderComments();
299 | };
300 | }
301 |
302 | export default ArchivalSpace;
303 |
--------------------------------------------------------------------------------
/src/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Togethernet
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
Ephemeral Channels +
39 |
40 | sitting-at-the-park
41 |
42 |
43 |
44 |
45 |
46 |
Archival Channel
47 |
48 | posting-on-a-bulletin-board
49 |
50 |
51 |
52 |
53 |
54 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
82 |
83 |
87 |
88 |
92 |
95 |
96 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
148 |
149 |
150 |
151 |
152 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 | ⇈ 0
217 | ⇄ 0
218 | ⇊ 0
219 | ᳵ 0
220 |
221 |
222 |
223 |
224 | Yes0
225 | Neutral0
226 | No0
227 |
228 |
229 |
230 |
234 |
235 |
236 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
--------------------------------------------------------------------------------
/CODEOFCONSENT0.1.md:
--------------------------------------------------------------------------------
1 | # Togethernet Code of Consent v0.1
2 |
3 | The Togethernet Code of Consent (CoC) is a specification that outlines the level of consent and protection that participants have while using the software. Consent is defined as the act of giving permission for something to occur, and we use that term in this document to refer to the permissions that are presented with regards to Togethernet’s users’ data.
4 |
5 | As you read through this Code of Consent, please consider the following invitations:
6 |
7 | We invite you to take your time.
8 | We invite you to dig deeper.
9 |
10 | We welcome your pace.
11 | We welcome your concerns.
12 |
13 | We invite your enthusiasm.
14 | We invite your participation.
15 |
16 | We acknowledge the trauma that accompanies surrendering information about ourselves in the digital realm without being given insight into how platforms work, and how they protect or expose us in the process.(1) In constructive resistance against that, we offer this document, intended to give you a clear understanding of the ethos and agreements behind this software and how they affect your privacy.
17 |
18 | Structurally informed by the F.R.I.E.S. model created by Planned Parenthood, we believe that a consentful software should be designed and built through the lens of being Freely given, Reversible, Informed, Enthusiastic and Specific. You can learn more about how each of these terms contribute to a more consentful software through the [Consentful Tech Zine](https://www.andalsotoo.net/wp-content/uploads/2018/10/Building-Consentful-Tech-Zine-SPREADS.pdf), published by Una Lee and Dann Toliver under [CC-BY 2017](https://creativecommons.org/licenses/by/4.0/legalcode).
19 |
20 | ## The Foundations
21 |
22 | Togethernet is a consentful digital archiving software in the form of a desktop web app that allows both peer-to-peer (P2P), traceless messaging as well as archived communications.
23 |
24 | ### Who should use this?
25 |
26 | This software is for you if you are an artist, designer, community organizer, technologist, researcher, educator, or student who is interested in –
27 |
28 | - Exiting surveillance capitalism
29 | - Participating in consentful communications on the web
30 | - Building community-owned digital archives
31 |
32 | Togethernet is a software in pursuit of intentional and ongoing consentful engagement that prioritizes the digital autonomy and privacy of our users. We do not sell any of our users’ data nor does the software run on advertising. Those who are seeking a digital environment designed with safety in mind are encouraged to use Togethernet.
33 |
34 | ### Access
35 |
36 | Togethernet aspires towards accessibility and inclusivity. We acknowledge that the alpha version of the software falls short of that aspiration, as it is not currently fully accessible to the Blind and low-vision community. It is our intention to dedicate future resources towards ensuring that upcoming versions of Togethernet are designed with the needs of this community at the forefront.
37 |
38 | ### Who shouldn’t use this?
39 |
40 | Togethernet uses WebRTC to conduct peer-to-peer communications, which is encrypted by default but exposes the participant's public IP address in the process of communicating to servers. This software will most likely not have adequate privacy protections for individuals working with highly sensitive issues and who are concerned with targeted surveillance.
41 |
42 | Instead we recommend you looking into [Signal](https://www.signal.org/), an end-to-end encrypted messenger that has been tested by cybersecurity experts all over the world and is trusted by journalists and activists who are working with sensitive content.
43 |
44 | Additionally, this software is not intended for people who are not invested in consentful engagement with others. While the establishment of a consentful digital environment begins within its design infrastructure, the maintenance of this environment relies on a dedicated community. Individuals whose intentions are to cause harm should not use Togethernet.
45 |
46 | ### On Developer Responsibilities
47 |
48 | Developers who choose to adapt Togethernet’s open-source code in their projects, licenced under [ACSL 1.4](https://github.com/together-support/togethernet/blob/main/LICENSE.md),(2) are expected to adhere to the principles of consent outlined and prioritized by this software, and not alter the code in a way that violates these principles of consent.
49 |
50 | It is imperative that open-source usage of this code aligns with its intended ethos of consent – developers who violate the privacy agreements established in this Code of Consent will be required to disaffiliate from the “Togethernet” name, and remove this Code of Consent document from the code entirely.
51 |
52 | We strongly encourage developers to review the [Consentful Tech Zine](https://www.andalsotoo.net/wp-content/uploads/2018/10/Building-Consentful-Tech-Zine-SPREADS.pdf) prior to altering the code in order to ensure that the software remains true to its foundational principles.
53 |
54 | #### System Requirements
55 |
56 | Togethernet is a desktop web app that launches inside the browser. Currently the application runs as intended on Firefox (version 85.0.2) and Chrome (version 88.0.4324.150).
57 |
58 | #### Capacity
59 |
60 | Togethernet intends to facilitate consentful communications, and as we know consent is easier to negotiate within a micro-community. Therefore by default the application will accommodate a maximum of 12 simultaneous participants.(3)
61 |
62 | #### Demo
63 |
64 | We invite you to [visit this link](https://togethernet.herokuapp.com/) to try out a demo version of the software. In order to test out the collaborative features, we encourage you to invite a friend to join.
65 |
66 | ## Software Architecture
67 |
68 | ### Peer to Peer Consent: Ephemeral Channel
69 |
70 | On a peer-to-peer level, Togethernet is designed to invite you to say “YES!” along the way.
71 |
72 | While using Togethernet, all text-based communications with your community take place under the Ephemeral Channels. Operating under the logic of peer-to-peer consent, the communication records created during the live session in Ephemeral Mode will be permanently deleted once the last participant closes their browser tab. To prevent messages from being deleted, participants need to go through the Consent to Archive process in order to publish the content to the Archival Channel, allowing the users to make informed decisions regarding their own privacy level while using Togethernet.
73 |
74 | In WebRTC, real time communications are achieved without needing to install any additional applications or plug-ins. The information that is transmitted between your browsers is anonymously and freely given by you as a user. There are no logins on Togethernet because on this peer-to-peer level, your identity is not recorded on a centralized system. Furthermore, WebRTC encrypts all data that is sent through it in order to protect users’ communications.
75 |
76 | If you are feeling confused at this point, we invite you to take a moment to imagine:
77 |
78 | You and a friend are meeting in the street. Your intentions are to share private information with one another, but prior to that happening, you both require exchanging a secret handshake. The information you share is functionally protected by the mediating act of completing the handshake, whose steps are known only by the two of you. This is akin to the level of privacy enabled by WebRTC’s encryption in peer-to-peer mode.
79 |
80 | Once your peers receive data sent from your browser, you have the agency to remove this data by deleting the messages. In the Ephemeral Channel, messages disappear once the last person in a given chat session departs. If you leave a session but return before the last person has departed, your messages will still be visible and accessible to those in the chat.
81 |
82 | #### The Role of the Server
83 |
84 | While WebRTC is distinctive in that it enables encrypted peer-to-peer connections via browsers, servers are still required in order to facilitate these connections. Think of the server as an impartial mediator.
85 |
86 | For a connection to be established between you and a peer, information on your respective locations must be exchanged over the server. The information that is given pertains to your local IP address (+ more), which takes place inside your browser and is executed by Javascript and being sent out to what is called the signaling server.(4) Once location data has been sent to the signaling server, it is not retrievable.
87 |
88 | Communication between you and your peer happens via offers and answers that are mediated by this signaling server. These offers, sent by the initiating browser to a receiving browser, occur once you have consented to begin an exchange on the software at the start of the software.
89 |
90 | ### Peer-to-Server: Archival Channel
91 |
92 | The Archival Channel operates under the logic of peer-to-server consent, where communications are published to a centralized database.
93 |
94 | Once messages are published to the Archival Channel, users can enter the room and become an editor. The first person to enter the room is automatically the editor. Subsequent users who enter the chat can become the editor by selecting the function in the bottom-left corner.(5)
95 |
96 | The editor is armed with the ability to delete a message without necessarily having collective consent; as such, this role is one that requires an abundance of care and trust by peer users. It is encouraged that users communicate with their peers prior to removing messages from the archive. When messages are deleted, a trace is left in the archive that labels the removed message as having been deleted by the user.
97 |
98 | The Archival Channel’s default database runs on [PostGres](https://www.postgresql.org/), an open source database that users can run on their own, or a third-party server. Messages in the Archival Channel can be downloaded into an HTML page, which gives users the ability to host the archive of their conversation on their own website if desired.
99 |
100 | It is important to note that data stored on third-party servers might not necessarily adhere to the same consent principles that are outlined in this document. A list of third-party servers and their consent adherence can be found [here](https://en.gendersec.train.tacticaltech.org/downloads/en/autonomous_and_ethical_hosting_providers.pdf).
101 |
102 | ### Recap: Adherence to F.R.I.E.S. Model
103 |
104 | To understand and place emphasis on how this software adheres to the Planned Parenthood F.R.I.E.S. consent framework, below are Togethernet’s answers to questions posed to developers in the [Consenftul Tech Zine](https://www.andalsotoo.net/wp-content/uploads/2018/10/Building-Consentful-Tech-Zine-SPREADS.pdf).
105 |
106 | Are people **Freely** giving us their consent to access and store parts of their digital bodies?
107 |
108 | Yes - this document is intended to arm prospective Togethernet users with an understanding of how their digital bodies are accessed, stored, and protected while using the software. Prospective users are invited to reach out with any questions or concerns in case they are not comfortable with the terms outlined here.
109 |
110 | Does your system allow for **Reversible** consent? How easy is it for people to withdraw both their consent and their data?
111 |
112 | Yes - in order for messages to be archived, collective consent from all parties in the conversation is required. This consent can be revoked at any time by any individual in the conversation. Revoking consent removes users’ data from the archive, but does not preclude users from continuing to use the software.
113 |
114 | How are we making sure that the consent is **Enthusiastic**? Is there an option not to use this technology, which means that people use it because they prefer to use it?
115 |
116 | In order to use Togethernet, it is required to read through and agree to the Code of Consent, which is aimed at clearly outlining the agreements behind the software. We offer this software, and the embedded Code of Consent as an alternative to softwares that are rooted in surveillance capitalism. For users whose privacy needs are not met by Togethernet, we invite them to explore alternatives such as Signal.
117 |
118 | How are we fully and clearly **Informing** people about what they’re consenting to? Is important information about the risks a user might be exposed to buried in the fine print of the terms & conditions?
119 |
120 | Through making use of repetition and robust citation, the information in this document is intended to be clear and consistent. Our intention is to be transparent in the potential risks involved with use.
121 |
122 | This Code of Consent is embedded as part of Togethernet’s Open Source software. As such, it is possible that this software will be re-used and altered in a way such that the code of consent is broken. In such an instance, the developers must take out the code of consent document from the software and disaffiliate from the Togethernet name.
123 |
124 | Can people consent to **Specific** things in this system and not others? Can people select which aspects of their digital bodies they want to have exposed and have stored?
125 |
126 | Yes - this is accomplished via the consent to archive function, which requests the consent of all participants prior to moving messages into the centralized database or third-party server. Consent to archive must be unanimous - if one participant in the group revokes their consent, the message is removed from the Archival Channel for all parties.
127 |
128 | ## Credits
129 |
130 | [Consentful Tech Zine](https://www.andalsotoo.net/wp-content/uploads/2018/10/Building-Consentful-Tech-Zine-SPREADS.pdf), written by Una Lee and Dann Toliver and published under CC-BY in 2017 had the foresight of using F.R.I.E.S. (Freely given, Informed, Specific, Reversible, Enthusiastic), a model of consent by Planned Parenthood as a metric to assess data consent in the digital sphere.
131 |
132 | Togethernet’s Code of Consent was compiled by Neema Githere and Xin Xin.
133 |
134 | --------------------------------------------
135 |
136 |
137 | If you feel you understand and agree to Togethernet’s Code of Consent, we invite you to continue onwards to the [orientation](https://togethernet.org/orientation.html). If you do not feel comfortable with the information that has been presented thus far, we invite you to stop to ask a question or present your concern. If you feel you fall somewhere in between, we encourage you to take your time digesting this information and return at your own pace.
138 |
139 | ----------------------------------------------
140 |
141 | Endnote:
142 |
143 | (1) See Also: [Data Trauma](https://www.bitchmedia.org/article/digital-doulas-fixing-data-trauma) by Olivia M. Ross.
144 | (2) We also ask that a robust politics of citation be adhered to in the adaptation and use of this code and its embedded Code of Consent.
145 | (3) It is possible to increase the number of participants by changing the environment variable inside the source code, however we don’t recommend doing so without first gaining a good familiarity with how the application works.
146 | (4) To learn more about the role of the signaling server, we invite you to visit [WebRTC.org](https://webrtc.org/getting-started/peer-connections).
147 | (5) There can only be one editor at a time in the Archival Channel, and since users maintain default anonymity even in this mode, participants are encouraged to label themselves with a recognizable name (though this does not necessarily need to be a formal or ‘real’ name).
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
--------------------------------------------------------------------------------
/src/public/js/EphemeralMessage/index.js:
--------------------------------------------------------------------------------
1 | import store from '@js/store';
2 | import { renderEphemeralDetails } from '@js/EphemeralMessageRenderer';
3 | import isPlainObject from 'lodash/isPlainObject';
4 | import {
5 | addSystemConfirmMessage,
6 | addSystemNotifyMessage,
7 | addSystemPopupMessage,
8 | clearSystemPopupMessage,
9 | clearSystemMessage
10 | } from '@js/Togethernet/systemMessage';
11 | import {
12 | systemConfirmMsgConfirmConsentToArchive,
13 | systemConfirmMsgConsentToArchive,
14 | systemConfirmMsgInitiateConsentToArchiveProcess,
15 | systemNotifyMsgBlockConsentToArchive,
16 | systemNotifyMsgGiveConsentToArchive,
17 | systemPopupMsgConsentToArchive
18 | } from '@js/constants.js';
19 | import sample from 'lodash/sample';
20 | import transform from 'lodash/transform';
21 | import pick from 'lodash/pick';
22 |
23 | export default class EphemeralMessage {
24 | constructor(props) {
25 | this.messageData = {
26 | ...props,
27 | id: `${props.roomId}-${props.gridColumnStart}-${props.gridRowStart}`,
28 | };
29 |
30 | if (props.inConsentToArchiveProcess) {
31 | const { consentToArchiveInitiator } = props;
32 | this.initConsentToArchiveReceived({ consentToArchiveInitiator });
33 | }
34 |
35 | if (
36 | props.threadEntryMessageId &&
37 | !props.threadPreviousMessageId &&
38 | !props.threadNextMessageId
39 | ) {
40 | this.setThreadInformation();
41 | }
42 | }
43 |
44 | setThreadInformation = () => {
45 | const { roomId, threadEntryMessageId } = this.messageData;
46 | const room = store.getRoom(roomId);
47 |
48 | const entryMessage = room.ephemeralHistory[threadEntryMessageId];
49 | const threadTail = entryMessage.getThreadTail();
50 |
51 | threadTail.messageData.threadNextMessageId = this.messageData.id;
52 | this.messageData.threadPreviousMessageId = threadTail.messageData.id;
53 | };
54 |
55 | getThreadTail = () => {
56 | const { roomId, threadNextMessageId } = this.messageData;
57 | if (!threadNextMessageId) {
58 | return this;
59 | }
60 |
61 | const ephemeralHistory = store.getRoom(roomId).ephemeralHistory;
62 | let threadNextMessage = ephemeralHistory[threadNextMessageId];
63 | while (threadNextMessage.messageData.threadNextMessageId) {
64 | threadNextMessage =
65 | ephemeralHistory[threadNextMessage.messageData.threadNextMessageId];
66 | }
67 |
68 | return threadNextMessage;
69 | };
70 |
71 | $textRecord = () => {
72 | return $(`#${this.messageData.id}`);
73 | };
74 |
75 | renderEphemeralMessageDetails = () => {
76 | $('.nonPinnedMessages').empty();
77 | $('.pinnedMessages').empty();
78 | $('.pinnedMessagesSummary i').addClass('collapsed');
79 |
80 | if (
81 | this.messageData.threadPreviousMessageId ||
82 | this.messageData.threadNextMessageId
83 | ) {
84 | this.renderThreadedDetails();
85 | } else {
86 | this.renderSingleEphemeralDetail();
87 | }
88 | $('.ephemeralMessageContainer').finish().show();
89 | };
90 |
91 | renderSingleEphemeralDetail = () => {
92 | const { isPinned, id, roomId } = this.messageData;
93 |
94 | const $messageContent = renderEphemeralDetails(roomId, id);
95 | if (isPinned) {
96 | $messageContent.appendTo($('.pinnedMessages'));
97 | $('.pinnedMessages').show();
98 | } else {
99 | $messageContent.appendTo($('.nonPinnedMessages'));
100 | }
101 | };
102 |
103 | renderThreadedDetails = () => {
104 | const {
105 | id,
106 | roomId,
107 | threadNextMessageId,
108 | threadPreviousMessageId,
109 | } = this.messageData;
110 | const { ephemeralHistory } = store.getRoom(roomId);
111 |
112 | const $thisMessageContent = renderEphemeralDetails(roomId, id);
113 | $thisMessageContent.appendTo($('.nonPinnedMessages'));
114 |
115 | let travelThreadNextMessageId = threadNextMessageId;
116 | let travelCurrentThreadTail = id;
117 |
118 | while (travelThreadNextMessageId) {
119 | const $messageContent = renderEphemeralDetails(
120 | roomId,
121 | travelThreadNextMessageId
122 | );
123 | $messageContent.insertAfter(
124 | `#ephemeralDetails-${travelCurrentThreadTail}`
125 | );
126 | travelCurrentThreadTail = travelThreadNextMessageId;
127 | const record = ephemeralHistory[travelThreadNextMessageId];
128 | travelThreadNextMessageId = record.messageData.threadNextMessageId;
129 | }
130 |
131 | let travelThreadPreviousMessageId = threadPreviousMessageId;
132 | let travelCurrentThreadHead = id;
133 | while (travelThreadPreviousMessageId) {
134 | const $messageContent = renderEphemeralDetails(
135 | roomId,
136 | travelThreadPreviousMessageId
137 | );
138 | $messageContent.insertBefore(
139 | `#ephemeralDetails-${travelCurrentThreadHead}`
140 | );
141 | travelCurrentThreadHead = travelThreadPreviousMessageId;
142 | const record = ephemeralHistory[travelThreadPreviousMessageId];
143 | travelThreadPreviousMessageId =
144 | record.messageData.threadPreviousMessageId;
145 | }
146 | };
147 |
148 | purgeSelf = () => {
149 | if (
150 | this.messageData.threadNextMessageId ||
151 | this.messageData.threadPreviousMessageId
152 | ) {
153 | this.handleRemoveMessageInThread();
154 | } else {
155 | this.handleRemoveSingleMessage();
156 | }
157 | };
158 |
159 | handleRemoveSingleMessage = () => {
160 | const room = store.getRoom(this.messageData.roomId);
161 | const $textRecord = this.$textRecord();
162 |
163 | $('.nonPinnedMessages').empty();
164 | $('.ephemeralMessageContainer').hide();
165 | $textRecord.finish().animate(
166 | { opacity: 0 },
167 | {
168 | complete: () => {
169 | $textRecord.remove();
170 | store.sendToPeers({
171 | type: 'removeEphemeralMessage',
172 | data: {
173 | messageId: this.messageData.id,
174 | roomId: this.messageData.roomId,
175 | },
176 | });
177 | room.removeEphemeralHistory(this.messageData.id);
178 | },
179 | }
180 | );
181 | };
182 |
183 | handleRemoveMessageInThread = () => {
184 | store.sendToPeers({
185 | type: 'removeMessageInThread',
186 | data: {
187 | roomId: this.messageData.roomId,
188 | messageId: this.messageData.id,
189 | },
190 | });
191 | this.clearMessageInThread();
192 | };
193 |
194 | clearMessageInThread = () => {
195 | this.messageData.content = '[message removed]';
196 | this.messageData.name = '';
197 | $(`#ephemeralDetails-${this.messageData.id}`).text('[message removed]');
198 | };
199 |
200 | castVote = (option) => {
201 | const { votingRecords, id } = this.messageData;
202 | const myId = store.getCurrentUser().socketId;
203 | const myCurrentVote = isPlainObject(votingRecords) && votingRecords[myId];
204 | const data = {
205 | textRecordId: id,
206 | option,
207 | ...store.getCurrentUser().getProfile(),
208 | };
209 |
210 | if (myCurrentVote) {
211 | if (myCurrentVote === option) {
212 | store.sendToPeers({ type: 'voteRetracted', data });
213 | this.voteRetracted(data);
214 | } else {
215 | store.sendToPeers({ type: 'voteChanged', data });
216 | this.voteChanged(data);
217 | }
218 | } else {
219 | store.sendToPeers({ type: 'voteCasted', data });
220 | this.voteReceived(data);
221 | }
222 | };
223 |
224 | voteReceived = ({ option, socketId }) => {
225 | const { votes = {}, votingRecords, id } = this.messageData;
226 | this.messageData.votes = {
227 | ...votes,
228 | [option]: isNaN(votes[option]) ? 1 : votes[option] + 1,
229 | };
230 | this.messageData.votingRecords = {
231 | ...votingRecords,
232 | [socketId]: option,
233 | };
234 |
235 | $(`#ephemeralDetails-${id} .voteOption.${option} .voteCount`).text(
236 | this.messageData.votes[option]
237 | );
238 | };
239 |
240 | voteRetracted = ({ option, socketId }) => {
241 | const { votes = {}, id } = this.messageData;
242 | this.messageData.votes = {
243 | ...votes,
244 | [option]: isNaN(votes[option]) ? 1 : votes[option] - 1,
245 | };
246 |
247 | if (isPlainObject(delete this.messageData.votingRecords)) {
248 | delete this.messageData.votingRecords[socketId];
249 | }
250 |
251 | $(`#ephemeralDetails-${id} .voteOption.${option} .voteCount`).text(
252 | this.messageData.votes[option]
253 | );
254 | };
255 |
256 | voteChanged = ({ option, socketId }) => {
257 | const currentVote = this.messageData.votingRecords[socketId];
258 | this.voteRetracted({ option: currentVote, socketId });
259 | this.voteReceived({ option, socketId });
260 | };
261 |
262 | createPoll = () => {
263 | const { roomId, id } = this.messageData;
264 | this.pollCreated();
265 | store.sendToPeers({
266 | type: 'pollCreated',
267 | data: { roomId, textRecordId: id },
268 | });
269 | };
270 |
271 | pollCreated = () => {
272 | this.messageData.canVote = true;
273 | this.votes = {
274 | yes: 0,
275 | no: 0,
276 | neutral: 0,
277 | };
278 | if ($(`#ephemeralDetails-${this.messageData.id}`).is(':visible')) {
279 | this.renderEphemeralMessageDetails();
280 | }
281 | };
282 |
283 | consentToArchiveButtonClicked = () => {
284 | addSystemConfirmMessage(systemConfirmMsgConfirmConsentToArchive, this);
285 | };
286 |
287 | initiateConsentToArchiveProcess = () => {
288 | const { roomId, id } = this.messageData;
289 | addSystemConfirmMessage(systemConfirmMsgInitiateConsentToArchiveProcess);
290 | store.sendToPeers({
291 | type: 'initConsentToArchiveProcess',
292 | data: {
293 | roomId,
294 | messageId: id,
295 | },
296 | });
297 |
298 | this.performConsentToArchive();
299 | };
300 |
301 | initConsentToArchiveReceived = ({ consentToArchiveInitiator }) => {
302 | if (consentToArchiveInitiator != null) {
303 | addSystemConfirmMessage({
304 | msgType: systemConfirmMsgConsentToArchive.msgType,
305 | msgHeader: systemConfirmMsgConsentToArchive.msgHeader,
306 | msgBody: `${consentToArchiveInitiator} ${systemConfirmMsgConsentToArchive.msgBody}`,
307 | msgFooter: systemConfirmMsgConsentToArchive.msgFooter,
308 | yayText: systemConfirmMsgConsentToArchive.yayText,
309 | nayText: systemConfirmMsgConsentToArchive.nayText,
310 | yayBtn: systemConfirmMsgConsentToArchive.yayBtn,
311 | nayBtn: systemConfirmMsgConsentToArchive.nayBtn,
312 | });
313 | }
314 |
315 | this.messageData.consentToArchiveInitiator = consentToArchiveInitiator;
316 | this.performConsentToArchive();
317 | };
318 |
319 | performConsentToArchive = () => {
320 | this.messageData.inConsentToArchiveProcess = true;
321 | const { roomId } = this.messageData;
322 | addSystemPopupMessage(systemPopupMsgConsentToArchive);
323 | this.$textRecord().addClass('inConsentProcess');
324 | $('#user .avatar').addClass('inConsentProcess');
325 | $('.ephemeralMessageContainer').addClass('inConsentProcess');
326 | $(`#${roomId}`).find('.consentToArchiveOverlay').show();
327 | $(`#${roomId}`).off('keyup', this.consentToArchiveActions);
328 | $(`#${roomId}`).on('keyup', this.consentToArchiveActions);
329 | $('.initConsentToArchiveProcess').hide();
330 | $('#writeMessage').attr('disabled', 'disabled');
331 | $('#writeMessage').attr('placeholder', 'Messaging currently unavailable');
332 |
333 | this.getMessagesInThread().forEach((message) =>
334 | message.$textRecord().addClass('inConsentProcess')
335 | );
336 | };
337 |
338 | getNextMessage = () => {
339 | const { roomId, threadNextMessageId } = this.messageData;
340 | if (threadNextMessageId) {
341 | const { ephemeralHistory } = store.getRoom(roomId);
342 | return ephemeralHistory[threadNextMessageId];
343 | }
344 | };
345 |
346 | getPreviousMessage = () => {
347 | const { roomId, threadPreviousMessageId } = this.messageData;
348 | if (threadPreviousMessageId) {
349 | const { ephemeralHistory } = store.getRoom(roomId);
350 | return ephemeralHistory[threadPreviousMessageId];
351 | }
352 | };
353 |
354 | getMessagesInThread = () => {
355 | const messagesInThread = [this];
356 |
357 | let travelThreadNextMessage = this.getNextMessage();
358 | while (travelThreadNextMessage) {
359 | messagesInThread.push(travelThreadNextMessage);
360 | travelThreadNextMessage = travelThreadNextMessage.getNextMessage();
361 | }
362 |
363 | let travelThreadPreviousMessage = this.getPreviousMessage();
364 | while (travelThreadPreviousMessage) {
365 | messagesInThread.push(travelThreadPreviousMessage);
366 | travelThreadPreviousMessage = travelThreadPreviousMessage.getPreviousMessage();
367 | }
368 |
369 | return messagesInThread;
370 | };
371 |
372 | consentToArchiveActions = (e) => {
373 | const {consentToArchiveRecords, inConsentToArchiveProcess} = this.messageData;
374 | if (!inConsentToArchiveProcess) {
375 | return;
376 | }
377 |
378 | const alreadyGaveConsent = isPlainObject(consentToArchiveRecords) && consentToArchiveRecords[store.getCurrentUser().socketId];
379 |
380 | if (e.key === 'y') {
381 | if (!alreadyGaveConsent) {
382 | this.giveConsentToArchive();
383 | }
384 | } else if (e.key === 's') {
385 | this.blockConsentToArchive();
386 | }
387 | };
388 |
389 | giveConsentToArchive = () => {
390 | this.consentToArchiveReceived(store.getCurrentUser());
391 | addSystemNotifyMessage(systemNotifyMsgGiveConsentToArchive);
392 | const { id, roomId } = this.messageData;
393 | const room = store.getRoom(roomId);
394 | store.sendToPeers({
395 | type: 'giveConsentToArchive',
396 | data: {
397 | roomId,
398 | messageId: id,
399 | },
400 | });
401 |
402 | if (Object.keys(this.messageData.consentToArchiveRecords).length === Object.keys(room.memberships.members).length) {
403 | this.archiveMessage();
404 | }
405 | }
406 |
407 | consentToArchiveReceived = (user) => {
408 | const {socketId, avatar} = user.getProfile();
409 | const {consentToArchiveRecords = {}} = this.messageData;
410 | if (!consentToArchiveRecords[socketId]) {
411 | this.messageData.consentToArchiveRecords = {
412 | ...consentToArchiveRecords,
413 | [socketId]: user.getProfile(),
414 | };
415 | }
416 |
417 | const size = Math.round(
418 | this.$textRecord().outerWidth() /
419 | (Math.floor(
420 | Math.sqrt(
421 | Object.keys(this.messageData.consentToArchiveRecords).length
422 | )
423 | ) +
424 | 1)
425 | );
426 | const $consentIndicator = $('
');
427 | $consentIndicator.css({ backgroundColor: avatar });
428 | $consentIndicator.width(size);
429 | $consentIndicator.height(size);
430 |
431 | this.$textRecord()
432 | .find('.consentIndicator')
433 | .each((_, el) => {
434 | $(el).width(size);
435 | $(el).height(size);
436 | });
437 |
438 | $consentIndicator.appendTo(this.$textRecord());
439 | }
440 |
441 | getArchivedMessageBody = () => {
442 | const {
443 | content,
444 | name,
445 | roomId,
446 | consentToArchiveRecords,
447 | threadNextMessageId,
448 | threadPreviousMessageId,
449 | } = this.messageData;
450 | let body = {
451 | author: name,
452 | content: content,
453 | room_id: roomId,
454 | participant_ids: Object.keys(consentToArchiveRecords),
455 | participant_names: Object.values(consentToArchiveRecords).map(
456 | (r) => r.name
457 | ),
458 | };
459 |
460 | if (threadNextMessageId || threadPreviousMessageId) {
461 | body.message_type = 'thread';
462 | body.thread_data = transform(
463 | this.getMessagesInThread(),
464 | (result, record) => {
465 | result[record.messageData.id] = pick(record.messageData, [
466 | 'name',
467 | 'content',
468 | 'threadNextMessageId',
469 | 'threadPreviousMessageId',
470 | ]);
471 | },
472 | {}
473 | );
474 | } else {
475 | body.message_type = 'text_message';
476 | }
477 | return JSON.stringify(body);
478 | };
479 |
480 | archiveMessage = () => {
481 | const { id, roomId } = this.messageData;
482 | const body = this.getArchivedMessageBody();
483 | fetch('/archive', {
484 | method: 'POST',
485 | headers: { 'Content-Type': 'application/json' },
486 | body,
487 | })
488 | .then((response) => response.json())
489 | .then((archivedMessage) => {
490 | this.messageArchived({ archivedMessageId: archivedMessage.id });
491 | store.sendToPeers({
492 | type: 'messageArchived',
493 | data: {
494 | roomId,
495 | messageId: id,
496 | archivedMessageId: archivedMessage.id,
497 | },
498 | });
499 | })
500 | .catch((e) => console.log(e));
501 | };
502 |
503 | messageArchived = ({ archivedMessageId }) => {
504 | const consentColors = Object.values(
505 | this.messageData.consentToArchiveRecords
506 | ).map((profile) => profile.avatar);
507 | this.getMessagesInThread().forEach((record) => {
508 | record.messageData.archivedMessageId = archivedMessageId;
509 | record.$textRecord().find('.consentIndicator').remove();
510 | Array.from({ length: 25 }).forEach(() => {
511 | const color = sample(consentColors);
512 | const $consentIndicator = $(
513 | '
'
514 | );
515 | $consentIndicator.css({ backgroundColor: color });
516 | $consentIndicator.appendTo(record.$textRecord());
517 | });
518 | });
519 |
520 | this.finishConsentToArchiveProcess();
521 | };
522 |
523 | blockConsentToArchive = () => {
524 | this.consentToArchiveBlocked();
525 | addSystemNotifyMessage(systemNotifyMsgBlockConsentToArchive);
526 |
527 | const { id, roomId } = this.messageData;
528 | store.sendToPeers({
529 | type: 'blockConsentToArchive',
530 | data: {
531 | roomId,
532 | messageId: id,
533 | },
534 | });
535 | };
536 |
537 | consentToArchiveBlocked = () => {
538 | this.$textRecord().find('.consentIndicator').remove();
539 | this.finishConsentToArchiveProcess();
540 | this.messageData.consentToArchiveRecords = {};
541 | this.messageData.archivedMessageId = null;
542 | $(`#ephemeralDetails-${this.messageData.id}`)
543 | .find('.initConsentToArchiveProcess')
544 | .removeClass('checked');
545 | };
546 |
547 | finishConsentToArchiveProcess = () => {
548 | this.messageData.inConsentToArchiveProcess = false;
549 |
550 | const { roomId } = this.messageData;
551 |
552 | this.getMessagesInThread().forEach((record) => {
553 | record.$textRecord().removeClass('inConsentProcess');
554 | });
555 |
556 | $(`#${roomId}`).find('#user .avatar').removeClass('inConsentProcess');
557 | $(`#${roomId}`).find('.consentToArchiveOverlay').hide();
558 | $(`#${roomId}`).off('keyup', this.consentToArchiveActions);
559 | clearSystemPopupMessage();
560 | $('#writeMessage').removeAttr('disabled');
561 | $('#writeMessage').attr('placeholder', 'Type your message here');
562 | };
563 |
564 | indicateMessagesInThread = () => {
565 | this.getMessagesInThread().forEach((record) =>
566 | record.$textRecord().finish().find('.threadedRecordOverlay').show()
567 | );
568 | };
569 |
570 | indicateThreadForbidden = () => {
571 | this.getMessagesInThread().forEach((record) =>
572 | record
573 | .$textRecord()
574 | .finish()
575 | .find('.threadedRecordForbiddenOverlay')
576 | .show()
577 | );
578 | };
579 |
580 | render = () => {
581 | const {
582 | id,
583 | gridColumnStart,
584 | gridRowStart,
585 | avatar,
586 | roomId,
587 | } = this.messageData;
588 |
589 | const $ephemeralRecordTemplate = $(
590 | document.getElementById('ephemeralRecordTemplate').content.cloneNode(true)
591 | );
592 | const $ephemeralRecord = $ephemeralRecordTemplate.find('.ephemeralRecord');
593 |
594 | $ephemeralRecord.attr('id', id);
595 | $ephemeralRecord[0].style.gridColumnStart = gridColumnStart;
596 | $ephemeralRecord[0].style.gridRowStart = gridRowStart;
597 |
598 | $ephemeralRecord
599 | .on('mouseenter', this.renderEphemeralMessageDetails)
600 | .on('mouseleave', () => {
601 | if (!store.getCurrentUser().getAdjacentMessageIds().includes(id)) {
602 | $('.ephemeralMessageContainer').finish().fadeOut(500);
603 | }
604 | });
605 |
606 | $ephemeralRecord.on('adjacent', this.renderEphemeralMessageDetails);
607 | $ephemeralRecord.on('indicateThread', this.indicateMessagesInThread);
608 | $ephemeralRecord.on(
609 | 'indicateThreadForbidden',
610 | this.indicateThreadForbidden
611 | );
612 |
613 | $ephemeralRecord.css({ backgroundColor: avatar });
614 | $ephemeralRecord.appendTo($(`#${roomId}`));
615 | };
616 | }
617 |
--------------------------------------------------------------------------------