├── .env ├── .nvmrc ├── test ├── playwright │ ├── Message.spec.js │ ├── Welcome.spec.js │ ├── ChangeUsername.spec.js │ ├── InfoModal.spec.js │ ├── SettingsModal.spec.js │ ├── SearchModal.spec.js │ ├── SetUsername.spec.js │ └── InviteUsers.spec.js ├── .setup.js └── utils │ └── tags.test.js ├── src ├── static │ ├── sass │ │ ├── _variables.scss │ │ ├── main.scss │ │ ├── _suggestions.scss │ │ ├── _emojiPicker.scss │ │ └── _layout.scss │ ├── fonts │ │ └── Lato.ttf │ ├── audio │ │ └── notification_gertz.wav │ ├── css │ │ └── Lato.css │ └── assets.json ├── utils │ ├── time.js │ ├── audio.js │ ├── origin_polyfill.js │ ├── emoji_convertor.js │ ├── detect_browser.js │ ├── chat.js │ ├── vh_fix.js │ ├── encrypter.js │ ├── link_attr_blank.js │ ├── pagevisibility.js │ ├── suggestions.js │ ├── tags.js │ └── sessions.js ├── constants │ ├── emoji.js │ └── messaging.js ├── store │ ├── epics │ │ ├── index.js │ │ ├── helpers │ │ │ ├── urls.js │ │ │ ├── createDetectPageVisibilityObservable.js │ │ │ └── ChatHandler.js │ │ └── chatEpics.js │ ├── actions │ │ ├── settingsActions.js │ │ ├── alertActions.js │ │ └── chatActions.js │ └── reducers │ │ ├── index.js │ │ ├── alertReducer.js │ │ ├── helpers │ │ └── deviceState.js │ │ ├── settingsReducer.js │ │ └── chatReducer.js ├── components │ ├── layout │ │ ├── Logo.js │ │ ├── ChatRoom.js │ │ ├── Info.js │ │ ├── Settings.js │ │ └── Header.js │ ├── chat │ │ ├── UserIcon.js │ │ ├── toolbar │ │ │ ├── InviteIcon.js │ │ │ ├── OpenSearchIcon.js │ │ │ └── ToggleAudioIcon.js │ │ ├── AutoSuggest.js │ │ ├── MentionSuggestions.js │ │ ├── ChatRoom.js │ │ ├── MessageList.js │ │ ├── ChatContainer.js │ │ ├── EmojiSuggestions.js │ │ ├── MessageBox.js │ │ ├── Message.js │ │ ├── UserList.js │ │ ├── UserStatusIcons.js │ │ └── MessageForm.js │ ├── general │ │ ├── AlertContainer.js │ │ └── Throbber.js │ ├── modals │ │ ├── SettingsModal.js │ │ ├── SharingModal.js │ │ ├── PincodeModal.js │ │ ├── InfoModal.js │ │ ├── SearchModal.js │ │ └── Username.js │ └── App.js ├── data │ ├── username.js │ └── minishare.js ├── index-template.ejs └── index.js ├── db ├── sql │ ├── init001.sql │ ├── migration001.sql │ ├── migration002.sql │ ├── migration003.sql │ ├── table01_rooms.sql │ ├── pre.sql │ └── table02_messages.sql ├── postgrest.conf ├── migrate.sh └── init_sql.sh ├── .editorconfig ├── fedora_install.sh ├── .babelrc ├── .gitignore ├── .github └── workflows │ └── pull_request_javascript_check.yml ├── webpack.config.dev.js ├── LICENSE.md ├── playwright.config.js ├── docker-compose.yml ├── go.mod ├── Makefile ├── gzip.go ├── json.go ├── webpack.config.prod.js ├── .eslintrc ├── server_test.go ├── leapchat.go ├── webpack.config.base.js ├── room_test.go ├── messages.go ├── package.json ├── pg_types.go ├── server.go ├── miniware └── miniware.go ├── room.go ├── go.sum └── README.md /.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.0.0 2 | -------------------------------------------------------------------------------- /test/playwright/Message.spec.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | $base-color: #111; 2 | -------------------------------------------------------------------------------- /db/sql/init001.sql: -------------------------------------------------------------------------------- 1 | create extension if not exists "uuid-ossp"; 2 | -------------------------------------------------------------------------------- /db/sql/migration001.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE messages ALTER COLUMN message SET DEFAULT ''; 2 | -------------------------------------------------------------------------------- /db/sql/migration002.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE messages ALTER COLUMN ttl_secs SET NOT NULL; 2 | -------------------------------------------------------------------------------- /src/static/fonts/Lato.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptag/leapchat/HEAD/src/static/fonts/Lato.ttf -------------------------------------------------------------------------------- /src/utils/time.js: -------------------------------------------------------------------------------- 1 | export function nowUTC(){ 2 | return new Date(new Date().toUTCString().substr(0, 25)); 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | indent_style = space 2 | indent_size = 2 3 | insert_final_newline = true 4 | trim_trailing_whitespace = true 5 | -------------------------------------------------------------------------------- /src/static/sass/main.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | @import 'layout'; 3 | @import 'emojiPicker'; 4 | @import 'suggestions'; 5 | -------------------------------------------------------------------------------- /src/utils/audio.js: -------------------------------------------------------------------------------- 1 | export function playNotification(){ 2 | document.getElementById('notification-audio').play(); 3 | } 4 | -------------------------------------------------------------------------------- /src/static/audio/notification_gertz.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptag/leapchat/HEAD/src/static/audio/notification_gertz.wav -------------------------------------------------------------------------------- /fedora_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Matthew Leeds 3 | # 2017.07.08 4 | 5 | sudo dnf install postgresql postgresql-server postgresql-contrib 6 | -------------------------------------------------------------------------------- /src/constants/emoji.js: -------------------------------------------------------------------------------- 1 | exports.EMOJI_APPLE_64_PATH = 'static/img/emoji/apple/64/'; 2 | exports.EMOJI_APPLE_64_SHEET = 'static/img/emoji/apple/sheets/64.png'; 3 | -------------------------------------------------------------------------------- /src/store/epics/index.js: -------------------------------------------------------------------------------- 1 | import { combineEpics } from 'redux-observable'; 2 | import chatEpics from './chatEpics'; 3 | 4 | export default combineEpics( 5 | chatEpics 6 | ); 7 | -------------------------------------------------------------------------------- /db/sql/migration003.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION delete_expired_messages() RETURNS void AS $$ 2 | DELETE FROM messages WHERE created + interval '1s' * ttl_secs < now(); 3 | $$ LANGUAGE SQL VOLATILE; 4 | -------------------------------------------------------------------------------- /db/sql/table01_rooms.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE rooms ( 2 | room_id text NOT NULL UNIQUE PRIMARY KEY CHECK (40 <= LENGTH(room_id) AND LENGTH(room_id) <= 55) 3 | ); 4 | ALTER TABLE rooms OWNER TO superuser; 5 | -------------------------------------------------------------------------------- /db/sql/pre.sql: -------------------------------------------------------------------------------- 1 | CREATE USER superuser WITH PASSWORD 'superuser'; 2 | CREATE DATABASE leapchat OWNER superuser ENCODING 'UTF8'; 3 | GRANT ALL ON DATABASE leapchat TO superuser; 4 | ALTER USER superuser CREATEDB; 5 | -------------------------------------------------------------------------------- /db/postgrest.conf: -------------------------------------------------------------------------------- 1 | db-uri = "postgres://superuser:superuser@localhost:5432/leapchat" 2 | db-schema = "public" 3 | db-anon-role = "superuser" 4 | db-pool = 10 5 | 6 | server-host = "127.0.0.1" 7 | server-port = 3000 8 | -------------------------------------------------------------------------------- /src/components/layout/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Logo = () => { 4 | return ( 5 |
6 | LeapChat 7 |
8 | ); 9 | }; 10 | 11 | export default Logo; -------------------------------------------------------------------------------- /src/static/css/Lato.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lato'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local("Lato Regular"), local("Lato-Regular"), url(Lato.ttf) format("truetype"); 6 | } 7 | -------------------------------------------------------------------------------- /db/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Steve Phillips / elimisteve 3 | # 2017.05.18 4 | 5 | set -euo pipefail 6 | 7 | # Run migrations 8 | for file in $*; do 9 | psql -U ${pg_user:-postgres} -d leapchat < "$file" 10 | done 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-env" 5 | ], 6 | "plugins": [ 7 | "system-import-transformer", 8 | "transform-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread" 10 | ] 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | node_modules/ 3 | build/ 4 | leapchat 5 | 6 | # bower static assets 7 | static/lib/ 8 | 9 | # docker container volumes 10 | _docker-volumes/ 11 | 12 | # Editors 13 | .vscode/ 14 | 15 | # Mac OS X Bullshit 16 | .DS_Store 17 | 18 | # Playwright tests 19 | playwright-report/ 20 | -------------------------------------------------------------------------------- /src/store/actions/settingsActions.js: -------------------------------------------------------------------------------- 1 | export const ENABLE_AUDIO = 'ENABLE_AUDIO'; 2 | export const DISABLE_AUDIO = 'DISABLE_AUDIO'; 3 | 4 | export const enableAudio = () => 5 | ({ type: ENABLE_AUDIO, isAudioEnabled: true }); 6 | 7 | export const disableAudio = () => 8 | ({ type: DISABLE_AUDIO, isAudioEnabled: false }); -------------------------------------------------------------------------------- /test/.setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('@babel/register')(); 4 | 5 | const jsdom = require("jsdom"); 6 | const { JSDOM } = jsdom; 7 | 8 | 9 | global.document = new JSDOM(``); 10 | 11 | global.window = document.window; 12 | 13 | global.navigator = window.navigator; 14 | 15 | -------------------------------------------------------------------------------- /src/utils/origin_polyfill.js: -------------------------------------------------------------------------------- 1 | // window.location.origin polyfill, as per 2 | // https://stackoverflow.com/a/25495161/197160 -- 3 | if (!window.location.origin) { 4 | window.location.origin = window.location.protocol + "//" + 5 | window.location.hostname + 6 | (window.location.port ? ':' + window.location.port : ''); 7 | } 8 | -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import chatReducer from './chatReducer'; 3 | import alertReducer from './alertReducer'; 4 | import settingsReducer from './settingsReducer'; 5 | 6 | export default combineReducers({ 7 | chat: chatReducer, 8 | alert: alertReducer, 9 | settings: settingsReducer, 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/emoji_convertor.js: -------------------------------------------------------------------------------- 1 | import { EmojiConvertor } from 'emoji-js'; 2 | import { EMOJI_APPLE_64_PATH, EMOJI_APPLE_64_SHEET } from '../constants/emoji'; 3 | 4 | const emoji = new EmojiConvertor(); 5 | 6 | emoji.allow_native = false; 7 | emoji.img_sets.apple.path = '/' + EMOJI_APPLE_64_PATH; 8 | emoji.img_sets.apple.sheet = '/' + EMOJI_APPLE_64_SHEET; 9 | 10 | export default emoji; 11 | -------------------------------------------------------------------------------- /src/data/username.js: -------------------------------------------------------------------------------- 1 | import { nouns, adjectives } from './constants'; 2 | 3 | function getRandomAdjective(){ 4 | return adjectives[Math.floor(Math.random()*adjectives.length)]; 5 | } 6 | 7 | function getRandomNoun(){ 8 | return nouns[Math.floor(Math.random()*nouns.length)]; 9 | } 10 | 11 | export function generateRandomUsername(){ 12 | return getRandomAdjective() + getRandomNoun(); 13 | } -------------------------------------------------------------------------------- /src/store/epics/helpers/urls.js: -------------------------------------------------------------------------------- 1 | 2 | let hostname; 3 | if (typeof window.location !== "undefined") { 4 | hostname = window.location.origin; 5 | } else { 6 | // hard-coded for mobile device scenarios 7 | hostname = "http://locahost:8080"; 8 | } 9 | 10 | export const authUrl = `${hostname}/api/login`; 11 | 12 | const wsHostname = hostname.replace('http', 'ws'); 13 | 14 | export const wsUrl = `${wsHostname}/api/ws/messages/all`; -------------------------------------------------------------------------------- /src/utils/detect_browser.js: -------------------------------------------------------------------------------- 1 | if (!window.browser) { 2 | const ua = navigator.userAgent; 3 | const browsers = ['Safari', 'MSIE', 'Firefox']; 4 | for (var i = 0; i < browsers.length; i++) { 5 | if (ua.indexOf(browsers[i]) > -1){ 6 | window.browser = browsers[i]; 7 | break; 8 | } 9 | } 10 | let Chrome = ua.indexOf('Chrome') > -1; 11 | if ((window.browser === 'Safari') && (Chrome)) window.browser = 'Chrome'; 12 | } 13 | -------------------------------------------------------------------------------- /src/index-template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/constants/messaging.js: -------------------------------------------------------------------------------- 1 | export const SERVER_ERROR_PREFIX = "Error from server: "; 2 | export const AUTH_ERROR = "Error authorizing you"; // Must match Go's miniware.AuthError 3 | export const ON_CLOSE_RECONNECT_MESSAGE = "Message WebSocket closed. Reconnecting..."; 4 | export const ONE_MINUTE = 60 * 1000; 5 | 6 | export const USER_STATUS_DELAY_MS = 10 * ONE_MINUTE; 7 | 8 | export const PARANOID_USERNAME = ' '; 9 | export const USERNAME_KEY = 'username'; 10 | -------------------------------------------------------------------------------- /test/playwright/Welcome.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("http://localhost:8080/"); 5 | }); 6 | 7 | test.describe('Welcome', () => { 8 | test('has title', async ({ page }) => { 9 | // Expect a title "to contain" a substring. 10 | await expect(page).toHaveTitle(/LeapChat/); 11 | 12 | await expect(page.getByTestId("set-username-form")).toBeVisible(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/chat/UserIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { FaUsers } from 'react-icons/fa'; 5 | 6 | const UserIcon = ({ onToggleUserList }) => { 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | UserIcon.propTypes = { 16 | onToggleUserList: PropTypes.func.isRequired 17 | }; 18 | 19 | export default UserIcon; 20 | -------------------------------------------------------------------------------- /db/sql/table02_messages.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE messages ( 2 | message_id uuid NOT NULL UNIQUE PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | room_id text NOT NULL REFERENCES rooms ON DELETE CASCADE, 4 | message text NOT NULL, 5 | message_enc bytea NOT NULL, 6 | ttl_secs integer DEFAULT 7776000 CHECK (60 <= ttl_secs AND ttl_secs <= 7776000), 7 | created timestamp WITH time zone DEFAULT now() 8 | ); 9 | ALTER TABLE messages OWNER TO superuser; 10 | -------------------------------------------------------------------------------- /src/static/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "stylesheets": [ 3 | "static/lib/bootstrap/dist/css/bootstrap.css", 4 | "static/css/main.css" 5 | ], 6 | "scripts": [ 7 | "static/lib/jquery/dist/jquery.js", 8 | "static/lib/bootstrap/dist/js/bootstrap.js", 9 | "static/lib/fetch/fetch.js", 10 | "static/js/crypto/blake2s.js", 11 | "static/js/crypto/nacl.js", 12 | "static/js/crypto/nacl-stream.js", 13 | "static/js/crypto/scrypt.js", 14 | "static/js/base58.js", 15 | "static/js/miniLock.js", 16 | "static/js/ui.js", 17 | "build/app.js" 18 | ] 19 | } -------------------------------------------------------------------------------- /db/init_sql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | # Create 'leapchat' database, associated role 6 | psql -d postgres < sql/pre.sql 7 | 8 | export pg_user=postgres 9 | if [ "`uname -s`" != "Linux" ]; then 10 | # For Mac OS X 11 | pg_user=$USER 12 | fi 13 | 14 | # More initialization 15 | for file in sql/init*.sql; do 16 | psql -U $pg_user -d leapchat < "$file" 17 | done 18 | 19 | # Create tables 20 | for file in sql/table*.sql; do 21 | psql -U $pg_user -d leapchat < "$file" 22 | done 23 | 24 | /bin/bash migrate.sh sql/migration*.sql 25 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_javascript_check.yml: -------------------------------------------------------------------------------- 1 | name: JavaScript Lint and Test Check 2 | 3 | on: 4 | pull_request: 5 | branches: [develop] 6 | 7 | jobs: 8 | run_eslinter: 9 | name: Runs ESLint and execute test suite 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 14.x 17 | - run: npm install 18 | - run: npm run lint 19 | - run: npm run mocha 20 | # - run: npx playwright install-deps 21 | # - run: npm test -------------------------------------------------------------------------------- /src/store/actions/alertActions.js: -------------------------------------------------------------------------------- 1 | export const ALERT_DISPLAY = 'ALERT_DISPLAY'; 2 | export const ALERT_DISMISS = 'ALERT_DISMISS'; 3 | 4 | export const alertSuccess = (message, alertRenderSeconds) => 5 | ({ type: ALERT_DISPLAY, style: 'success', message, alertRenderSeconds }); 6 | 7 | export const alertWarning = (message, alertRenderSeconds) => 8 | ({ type: ALERT_DISPLAY, style: 'warning', message, alertRenderSeconds }); 9 | 10 | export const alertDanger = (message, alertRenderSeconds) => 11 | ({ type: ALERT_DISPLAY, style: 'danger', message, alertRenderSeconds }); 12 | 13 | export const dismissAlert = () => 14 | ({ type: ALERT_DISMISS }); 15 | -------------------------------------------------------------------------------- /src/utils/chat.js: -------------------------------------------------------------------------------- 1 | import { tagByPrefix, tagByPrefixStripped, parseJSON, sortRowByCreated } from './tags'; 2 | 3 | export function extractMessageMetadata(tags) { 4 | return { 5 | isChatMessage: tags.indexOf('type:chatmessage') !== -1, 6 | isUserStatus: tags.indexOf('type:userstatus') !== -1, 7 | isPicture: tags.indexOf('type:picture') !== -1, 8 | isRoomName: tags.indexOf('type:roomname') !== -1, 9 | isRoomDescription: tags.indexOf('type:roomdescription') !== -1, 10 | from: tagByPrefixStripped(tags, 'from:'), 11 | to: tagByPrefixStripped(tags, 'to:'), 12 | status: tagByPrefixStripped(tags, 'status:') 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /test/playwright/ChangeUsername.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const username = "LeapChatUser"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto("http://localhost:8080/"); 7 | await page.locator("#username").fill(username); 8 | await page.getByTestId("set-username").click(); 9 | }); 10 | 11 | test.describe("Opens username modal", () => { 12 | test("opens the user modal from user list", async ({ page }) => { 13 | await expect(page.locator("#username")).not.toBeVisible(); 14 | 15 | await page.getByTestId("edit-username").click(); 16 | 17 | await expect(page.locator("#username")).toBeVisible(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/store/reducers/alertReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | ALERT_DISPLAY, 3 | ALERT_DISMISS 4 | } from '../actions/alertActions'; 5 | 6 | 7 | const initialState = { 8 | alertMessage: '', 9 | alertStyle: '', 10 | alertRenderSeconds: 0, 11 | }; 12 | 13 | function alertReducer(state = initialState, action) { 14 | 15 | switch (action.type) { 16 | case ALERT_DISPLAY: 17 | return { 18 | alertMessage: action.message, 19 | alertStyle: action.style, 20 | alertRenderSeconds: action?.alertRenderSeconds, 21 | }; 22 | 23 | case ALERT_DISMISS: 24 | return initialState; 25 | 26 | default: 27 | return state; 28 | } 29 | } 30 | 31 | export default alertReducer; 32 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config.base'); 2 | 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | config.entry = './src'; 6 | 7 | config.mode = "development"; 8 | 9 | config.watch = true; 10 | 11 | config.watchOptions = { 12 | aggregateTimeout: 300, 13 | poll: 1000 14 | }; 15 | 16 | config.module.rules = [ 17 | ...config.module.rules, 18 | { 19 | test: /\.css$/i, 20 | use: [MiniCssExtractPlugin.loader, "css-loader"], 21 | }, 22 | { 23 | test: /\.s[ac]ss$/i, 24 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], 25 | } 26 | ]; 27 | 28 | config.devtool = 'source-map'; 29 | 30 | module.exports = config; -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 CrypTag 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | -------------------------------------------------------------------------------- /src/components/layout/ChatRoom.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class ChatRoom extends Component { 4 | render(){ 5 | let username = this.props.username; 6 | let rooms = this.props.rooms; 7 | 8 | return ( 9 |
10 | {(this.props.room.messages || []).map(message => { 11 | let fromMe = (message.from === username); 12 | return ( 13 |
14 | {message.from}: {message.msg} 15 |
16 | ); 17 | })} 18 |
19 | ); 20 | } 21 | } 22 | 23 | export default ChatRoom; 24 | -------------------------------------------------------------------------------- /src/utils/vh_fix.js: -------------------------------------------------------------------------------- 1 | import { Capacitor } from '@capacitor/core'; 2 | 3 | function vh() { 4 | return (window.innerHeight * 0.01) + 'px'; 5 | } 6 | document.documentElement.style.setProperty('--vh', vh()); 7 | 8 | var lastHeight = window.innerHeight; 9 | 10 | window.addEventListener('resize', () => { 11 | if (window.innerWidth > window.innerHeight || 12 | Math.abs(lastHeight - window.innerHeight) > 100) { 13 | document.documentElement.style.setProperty(`--vh`, vh()); 14 | lastHeight = window.innerHeight; 15 | } 16 | }); 17 | 18 | 19 | document.documentElement.style.setProperty('--androidTopBar', '0px'); 20 | if (Capacitor.isNativePlatform()) { 21 | document.documentElement.style.setProperty('--androidTopBar', '36px'); 22 | } 23 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig, devices } = require('@playwright/test'); 2 | 3 | module.exports = defineConfig({ 4 | testDir: "./test/playwright", 5 | expect: { 6 | timout: 5000 7 | }, 8 | fullyParallel: true, 9 | reporter: 'html', 10 | use: { 11 | // does not work, wtf? 12 | // baseUrl: "http://localhost:8080/", 13 | timeout: 5 * 1000, 14 | headless: true // change to 'false' if you want to see a browser open 15 | }, 16 | projects: [ 17 | { 18 | name: 'webkit', 19 | use: { ...devices['Desktop Safari'] }, 20 | }, 21 | { 22 | name: 'firefox', 23 | use: { ...devices['Desktop Firefox'] }, 24 | } //, 25 | // { 26 | // name: 'chromium', 27 | // use: { ...devices['Desktop Chrome'] }, 28 | // }, 29 | 30 | ] 31 | }); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | postgres: 5 | image: postgres:latest 6 | ports: 7 | - 127.0.0.1:5432:5432 8 | environment: 9 | - POSTGRES_PASSWORD=superuser 10 | - POSTGRES_USER=superuser 11 | - POSTGRES_DB=leapchat 12 | volumes: 13 | - ./_docker-volumes/postgres:/var/lib/postgresql/data 14 | postgrest: 15 | image: postgrest/postgrest:latest 16 | ports: 17 | - 3000:3000 18 | environment: 19 | PGUSER: superuser 20 | PGPASSWORD: superuser 21 | PGHOST: postgres 22 | PGPORT: 5432 23 | PGDATABASE: leapchat 24 | PGSCHEMA: public 25 | DB_ANON_ROLE: postgres 26 | depends_on: 27 | - postgres 28 | -------------------------------------------------------------------------------- /src/components/layout/Info.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 4 | 5 | import { FaInfoCircle } from 'react-icons/fa'; 6 | 7 | import InfoModal from '../modals/InfoModal'; 8 | 9 | const infoTooltip = ( 10 | Open LeapChat Info 11 | ); 12 | 13 | const Info = () => { 14 | const [showInfoModal, setShowInfoModal] = useState(false); 15 | 16 | return ( 17 |
18 | setShowInfoModal(true)} size={19}/> 19 | {showInfoModal && setShowInfoModal(false)} />} 22 |
23 | ); 24 | }; 25 | 26 | Info.propTypes = {}; 27 | 28 | export default Info; 29 | -------------------------------------------------------------------------------- /src/store/reducers/helpers/deviceState.js: -------------------------------------------------------------------------------- 1 | import { USERNAME_KEY } from "../../../constants/messaging"; 2 | 3 | 4 | export const persistUsername = (username) => { 5 | if (typeof localStorage !== "undefined"){ 6 | localStorage.setItem(USERNAME_KEY, username); 7 | } 8 | }; 9 | 10 | export const getPersistedUsername = () => { 11 | if (typeof localStorage !== "undefined"){ 12 | return localStorage.getItem(USERNAME_KEY) || ''; 13 | } 14 | return ''; 15 | }; 16 | 17 | export const getIsParanoidMode = () => { 18 | if (typeof document !== "undefined"){ 19 | return document.location.hash.endsWith('----') || false; 20 | } 21 | return false; 22 | }; 23 | 24 | export const getIsPincodeRequired = () => { 25 | if (typeof document !== "undefined") { 26 | return document.location.hash.endsWith('--') || false; 27 | } 28 | return false; 29 | }; 30 | -------------------------------------------------------------------------------- /src/store/reducers/settingsReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | ENABLE_AUDIO, 3 | DISABLE_AUDIO, 4 | } from '../actions/settingsActions'; 5 | 6 | 7 | const initialState = { 8 | // Default to false on initial render so audio doesn't attempt to play before 9 | // user interacts with page (which triggers console.error) 10 | isAudioEnabled: false 11 | }; 12 | 13 | function settingsReducer(state = initialState, action) { 14 | 15 | switch (action.type) { 16 | case ENABLE_AUDIO: 17 | localStorage.setItem("isAudioEnabled", action.isAudioEnabled); 18 | return { 19 | isAudioEnabled: action.isAudioEnabled 20 | }; 21 | 22 | case DISABLE_AUDIO: 23 | localStorage.setItem("isAudioEnabled", action.isAudioEnabled); 24 | return { 25 | isAudioEnabled: action.isAudioEnabled 26 | }; 27 | 28 | default: 29 | return state; 30 | } 31 | } 32 | 33 | export default settingsReducer; 34 | -------------------------------------------------------------------------------- /test/playwright/InfoModal.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const username = "LeapChatUser"; 4 | 5 | 6 | test.beforeEach(async ({ page }) => { 7 | // set username 8 | await page.goto("http://localhost:8080/"); 9 | await page.locator("#username").fill(username); 10 | await page.getByTestId("set-username").click(); 11 | }); 12 | 13 | 14 | test.describe('Opens modal dialogs', () => { 15 | test('can open and view info dialog', async ({ page }) => { 16 | await expect(page.getByText("Welcome to LeapChat!")).not.toBeVisible(); 17 | 18 | // open sesame 19 | await page.locator(".info").click(); 20 | await expect(page.getByText("Welcome to LeapChat!")).toBeVisible(); 21 | 22 | // shut yourself sesame 23 | await page.locator(".modal-header .btn-close").click(); 24 | await expect(page.getByText("Welcome to LeapChat!")).not.toBeVisible(); 25 | }); 26 | }); -------------------------------------------------------------------------------- /src/utils/encrypter.js: -------------------------------------------------------------------------------- 1 | import { genPassphrase } from '../data/minishare'; 2 | 3 | const sha384 = require('js-sha512').sha384; 4 | 5 | const emailDomain = '@cryptag.org'; 6 | 7 | export function getEmail(passphrase){ 8 | return sha384(passphrase) + emailDomain; 9 | } 10 | 11 | export function getPassphrase(documentHash){ 12 | let isNewPassphrase = false; 13 | 14 | let passphrase = documentHash || '#'; 15 | passphrase = passphrase.slice(1); 16 | 17 | // Generate new passphrase for user if none specified (that is, if the 18 | // URL hash is blank) 19 | if (!passphrase){ 20 | passphrase = genPassphrase(); 21 | isNewPassphrase = true; 22 | } 23 | 24 | return { 25 | passphrase, 26 | isNewPassphrase 27 | }; 28 | } 29 | 30 | // TODO: Do smarter msgKey creation 31 | export function generateMessageKey(i){ 32 | let date = new Date(); 33 | return date.toGMTString() + ' - ' + date.getSeconds() + '.' + date.getMilliseconds() + '.' + i; 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cryptag/leapchat 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/cathalgarvey/base58 v0.0.0-20150930172411-5e83fd6f66e3 // indirect 7 | github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e // indirect 8 | github.com/cryptag/go-minilock v0.0.0-20230307201426-f138c5839651 9 | github.com/cryptag/gosecure v0.0.0-20180117073251-9b5880940d72 10 | github.com/dchest/blake2s v1.0.0 // indirect 11 | github.com/gorilla/context v1.1.1 12 | github.com/gorilla/mux v1.7.1 13 | github.com/gorilla/websocket v1.4.1 14 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da 15 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect 16 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d 17 | github.com/sirupsen/logrus v1.4.1 18 | github.com/stretchr/testify v1.8.2 19 | github.com/tv42/base58 v1.0.0 // indirect 20 | golang.org/x/crypto v0.7.0 21 | launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /src/data/minishare.js: -------------------------------------------------------------------------------- 1 | const crypto = window.crypto || window.msCrypto; 2 | 3 | import effWordlist from './effWordlist'; 4 | 5 | // Excluding max 6 | // 7 | // Mostly from https://github.com/chancejs/chancejs/issues/232#issuecomment-182500222 8 | function randomIntsInRange(min, max, numInts){ 9 | let rand = new Uint32Array(numInts); 10 | crypto.getRandomValues(rand); 11 | 12 | let ints = new Uint32Array(numInts); 13 | var zeroToOne = 0.0; 14 | 15 | for(let i = 0; i < numInts; i++){ 16 | zeroToOne = rand[i]/(0xffffffff + 1); 17 | // TODO: Do security audit of this for timing attacks 18 | ints[i] = Math.floor(zeroToOne * (max - min)) + min; 19 | } 20 | 21 | return ints; 22 | } 23 | 24 | export function genPassphrase(numWords=25){ 25 | let words = new Array(numWords); 26 | let ndxs = randomIntsInRange(0, effWordlist.length, numWords); 27 | 28 | for(let i = 0; i < numWords; i++){ 29 | words[i] = effWordlist[ndxs[i]]; 30 | } 31 | 32 | return words.join(""); 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/link_attr_blank.js: -------------------------------------------------------------------------------- 1 | const md = require('markdown-it')({ 2 | html: false, 3 | linkify: true, 4 | typographer: false 5 | }); 6 | 7 | const assignAttributes = (tokens, idx, attrObj) => { 8 | Object.keys(attrObj).forEach((attr) => { 9 | const aIndex = tokens[idx].attrIndex(attr); 10 | if (aIndex < 0) { 11 | tokens[idx].attrPush([attr, attrObj[attr]]); // add new attribute 12 | } else { 13 | tokens[idx].attrs[aIndex][1] = attrObj[attr]; // replace value of existing attr 14 | } 15 | }); 16 | }; 17 | const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { 18 | return self.renderToken(tokens, idx, options); 19 | }; 20 | 21 | md.renderer.rules.link_open = function (tokens, idx, options, env, self) { 22 | assignAttributes(tokens, idx, { 23 | target: '_blank', 24 | rel: 'nofollow noreferrer noopener' 25 | }); 26 | // pass token to default renderer. 27 | return defaultRender(tokens, idx, options, env, self); 28 | }; 29 | 30 | export default md; 31 | -------------------------------------------------------------------------------- /src/components/layout/Settings.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PropTypes } from 'prop-types'; 3 | 4 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 5 | 6 | import { FaCog } from 'react-icons/fa'; 7 | 8 | import SettingsModal from '../modals/SettingsModal'; 9 | 10 | const settingsTooltip = ( 11 | Open Settings 12 | ); 13 | 14 | const Settings = () => { 15 | const [showSettingsModal, setShowSettingsModal] = useState(false); 16 | 17 | return ( 18 |
19 | {/* */} 20 | {/* */} 21 | setShowSettingsModal(true)} /> 22 | {showSettingsModal && setShowSettingsModal(false)} />} 25 |
26 | ); 27 | }; 28 | 29 | Settings.propTypes = {}; 30 | 31 | export default Settings; 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build 3 | npm run build 4 | 5 | release: check-env 6 | @echo 'Hopefully "git diff" is empty!' 7 | @echo 'Creating release for version $(version) ...' 8 | @echo 'Manually change the version in these 2 files to $(version) and I, your loyal, Makefile, shall do the rest!' 9 | @emacsclient -t package.json 10 | @emacsclient -t package-lock.json 11 | @git add -p package.json package-lock.json 12 | @git commit -m 'Version bump to v$(version)' 13 | @git tag v$(version) 14 | @git push --tags origin develop 15 | 16 | deploy: check-env 17 | @tar zcvpf releases/leapchat-v$(version)-$$(mydate.sh).tar.gz ./leapchat ./db ./build 18 | $(MAKE) upload 19 | 20 | upload: 21 | @scp $$(ls -t releases/*.tar.gz | head -1) leapchat-minishare:~/gocode/src/github.com/cryptag/leapchat/releases/ 22 | @ssh leapchat-minishare 23 | 24 | all-deploy: 25 | $(MAKE) -B build 26 | $(MAKE) release 27 | $(MAKE) deploy 28 | 29 | check-env: 30 | ifndef version 31 | $(error "version" variable is undefined; re-run with "version=1.2.3" or similar) 32 | endif 33 | -------------------------------------------------------------------------------- /src/components/chat/toolbar/InviteIcon.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 5 | 6 | import { FaShareAltSquare } from 'react-icons/fa'; 7 | 8 | import SharingModal from '../../modals/SharingModal'; 9 | 10 | const shareChatTooltip = ( 11 | Invite to Chat 12 | ); 13 | 14 | const InviteIcon = () => { 15 | 16 | const [showSharingModal, setShowSharingModal] = useState(false); 17 | 18 | return ( 19 |
20 | {/* */} 21 | {/* */} 22 | setShowSharingModal(true)} /> 23 | {showSharingModal && setShowSharingModal(false)} />} 26 |
27 | ); 28 | }; 29 | 30 | InviteIcon.propTypes = {}; 31 | 32 | export default InviteIcon; 33 | -------------------------------------------------------------------------------- /src/components/chat/AutoSuggest.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import emoji from '../../utils/emoji_convertor'; 4 | import md from '../../utils/link_attr_blank'; 5 | import MentionSuggestions from './MentionSuggestions'; 6 | import EmojiSuggestions from './EmojiSuggestions'; 7 | 8 | const AutoSuggest = ({ chat }) => { 9 | 10 | const isMentions = chat.suggestionWord[0] === '@'; 11 | 12 | return ( 13 |
14 |
15 | { isMentions 16 | ? 'User' 17 | :'Emoji' 18 | } matching "{chat.suggestionWord}" 19 | 20 | to navigate 21 | to select 22 | 23 |
24 | { isMentions 25 | ? 26 | : 27 | } 28 |
29 | ); 30 | }; 31 | 32 | 33 | export default connect(({ chat }) => ({chat}))(AutoSuggest); 34 | -------------------------------------------------------------------------------- /src/components/chat/MentionSuggestions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { addSuggestion } from '../../store/actions/chatActions'; 4 | import { scrollIntoViewOptions } from '../../utils/suggestions'; 5 | import { UserStatusIconBubble } from './UserStatusIcons'; 6 | const MentionSuggestions = ({ chat, addSuggestion }) => ( 7 | 28 | ); 29 | 30 | export default connect(({ chat }) => ({chat}), { addSuggestion })(MentionSuggestions); 31 | -------------------------------------------------------------------------------- /test/playwright/SettingsModal.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const username = "LeapChatUser"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto("http://localhost:8080/"); 7 | await page.locator("#username").fill(username); 8 | await page.getByTestId("set-username").click(); 9 | }); 10 | 11 | test.describe("Opens settings modal", () => { 12 | test("opens and closes the settings modal by clicking gear icon", async ({ page }) => { 13 | await expect(page.getByText("Settings")).not.toBeVisible(); 14 | 15 | await page.locator(".settings").click(); 16 | await expect(page.getByText("Settings")).toBeVisible(); 17 | 18 | await page.locator(".modal-header .btn-close").click(); 19 | await expect(page.getByText("Settings")).not.toBeVisible(); 20 | }); 21 | 22 | test("purges chat of all messages when delete all message button is clicked", async ({ page }) => { 23 | // TODO configure a whole mess of messages in the DOM from multiple users, then check that DOM is 24 | // cleared of all message bubble elements on page refresh. 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/chat/ChatRoom.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class ChatRoom extends Component { 4 | onSelectRoom(){ 5 | let roomKey = this.props.chatRoom.key; 6 | this.props.onSelectRoom(roomKey); 7 | } 8 | 9 | render(){ 10 | let chatRoom = this.props.chatRoom; 11 | return ( 12 |
  • 13 | {chatRoom.roomname} 14 |
  • 15 | ); 16 | } 17 | } 18 | 19 | // class ChatRoom extends Component { 20 | // render(){ 21 | // let username = this.props.username; 22 | // let room = this.props.room; 23 | // 24 | // return ( 25 | //
    26 | // {(room.messages || []).map(message => { 27 | // let fromMe = (message.from === username); 28 | // return ( 29 | //
    30 | // {message.from}: {message.msg} 31 | //
    32 | // ) 33 | // })} 34 | //
    35 | // ) 36 | // } 37 | // } 38 | 39 | export default ChatRoom; 40 | -------------------------------------------------------------------------------- /src/components/chat/MessageList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { findDOMNode } from 'react-dom'; 3 | 4 | import Message from './Message'; 5 | 6 | class MessageList extends Component { 7 | 8 | shouldComponentUpdate(nextProps, nextState) { 9 | return nextProps.messages.length > 0 10 | && nextProps.messages 11 | .map(m => m.id) 12 | .concat(this.props.messages.map(m => m.id)) 13 | .reduce((acc, id) => { 14 | if(!acc.indexOf(id) === -1){ 15 | acc.push(id); 16 | } 17 | return acc; 18 | }, []) 19 | .length !== this.props.messages; 20 | } 21 | 22 | componentDidUpdate(){ 23 | this.props.onNewMessages(); 24 | } 25 | 26 | render() { 27 | const { messages, username } = this.props; 28 | 29 | return ( 30 | 40 | ); 41 | } 42 | } 43 | 44 | export default MessageList; 45 | -------------------------------------------------------------------------------- /gzip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | // GZip solution derived from 11 | // https://www.lemoda.net/go/gzip-handler/index.html and 12 | // https://stackoverflow.com/a/50898293/197160 13 | 14 | type gzipResponseWriter struct { 15 | io.Writer 16 | http.ResponseWriter 17 | } 18 | 19 | // Use the Writer part of gzipResponseWriter to write the output. 20 | 21 | func (w gzipResponseWriter) Write(b []byte) (int, error) { 22 | return w.Writer.Write(b) 23 | } 24 | 25 | func inAnyStr(s string, container []string) bool { 26 | for i := 0; i < len(container); i++ { 27 | if strings.Contains(container[i], s) { 28 | return true 29 | } 30 | } 31 | return false 32 | } 33 | 34 | func gzipHandler(h http.Handler) http.Handler { 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | if inAnyStr("gzip", r.Header["Accept-Encoding"]) { 37 | w.Header().Set("Content-Encoding", "gzip") 38 | gz := gzip.NewWriter(w) 39 | defer gz.Close() 40 | h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r) 41 | return 42 | } 43 | h.ServeHTTP(w, r) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | // Steve Phillips / elimisteve 2 | // 2017.01.16 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/gorilla/websocket" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const contentTypeJSON = "application/json; charset=utf-8" 15 | 16 | func WriteError(w http.ResponseWriter, errStr string, secretErr error) error { 17 | return WriteErrorStatus(w, errStr, secretErr, http.StatusInternalServerError) 18 | } 19 | 20 | func WriteErrorStatus(w http.ResponseWriter, errStr string, secretErr error, status int) error { 21 | log.Debugf("Real error: %v", secretErr) 22 | log.Debugf("Returning HTTP %d w/error: %q", status, errStr) 23 | 24 | w.Header().Set("Content-Type", contentTypeJSON) 25 | w.WriteHeader(status) 26 | _, err := fmt.Fprintf(w, `{"error":%q}`, errStr) 27 | return err 28 | } 29 | 30 | // WebSockets 31 | 32 | func WSWriteError(wsConn *websocket.Conn, errStr string, secretErr error) error { 33 | log.Debugf("WebSocket error: " + secretErr.Error()) 34 | 35 | wsErr := fmt.Sprintf(`{"error":%q}`, errStr) 36 | err := wsConn.WriteMessage(websocket.TextMessage, []byte(wsErr)) 37 | wsConn.Close() // TODO: Will this panic? 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /src/components/chat/ChatContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { PropTypes } from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import MessageBox from './MessageBox'; 6 | import MessageForm from './MessageForm'; 7 | import AutoSuggest from './AutoSuggest'; 8 | import AlertContainer from '../general/AlertContainer'; 9 | 10 | const ChatContainer = ({ 11 | suggestions, 12 | messageInputFocus, 13 | onToggleModalVisibility 14 | }) => { 15 | 16 | return ( 17 |
    18 | 19 | 20 | 21 | 22 | 23 | {suggestions.length > 0 && } 24 | 25 | 28 | 29 |
    30 | ); 31 | }; 32 | 33 | ChatContainer.propTypes = { 34 | messageInputFocus: PropTypes.bool.isRequired, 35 | onToggleModalVisibility: PropTypes.func.isRequired, 36 | }; 37 | 38 | const mapStateToProps = (reduxState) => { 39 | return { 40 | suggestions: reduxState.chat.suggestions, 41 | }; 42 | }; 43 | 44 | export default connect(mapStateToProps)(ChatContainer); 45 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const config = require('./webpack.config.base'); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | config.entry = { 6 | main: './src', 7 | }; 8 | 9 | config.mode = "production"; 10 | 11 | config.module.rules = [ 12 | ...config.module.rules, 13 | 14 | { 15 | test: /\.css$/, 16 | use: [ 17 | MiniCssExtractPlugin.loader, 18 | { 19 | loader: 'css-loader', 20 | options: { 21 | importLoaders: 2 22 | } 23 | } 24 | ] 25 | }, 26 | 27 | { 28 | test: /\.scss$/, 29 | use: [ 30 | MiniCssExtractPlugin.loader, 31 | { 32 | loader: 'css-loader', 33 | options: { 34 | importLoaders: 2 35 | } 36 | }, 37 | 'sass-loader' 38 | ] 39 | }, 40 | ]; 41 | 42 | config.optimization = { 43 | minimize: true, 44 | splitChunks: { 45 | name: 'manifest', 46 | }, 47 | }; 48 | 49 | config.plugins = [ 50 | ...config.plugins, 51 | new webpack.DefinePlugin({ 52 | 'process.env': { 53 | NODE_ENV: JSON.stringify('production') 54 | } 55 | }), 56 | ] 57 | 58 | module.exports = config; 59 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "plugins": [ 4 | "@babel", 5 | "react" 6 | ], 7 | "parserOptions": { 8 | "ecmaFeatures": { 9 | "arrowFunctions": true, 10 | "binaryLiterals": true, 11 | "blockBindings": true, 12 | "classes": true, 13 | "defaultParams": true, 14 | "destructuring": true, 15 | "forOf": true, 16 | "modules": true, 17 | "objectLiteralComputedProperties": true, 18 | "objectLiteralDuplicateProperties": false, 19 | "objectLiteralShorthandMethods": true, 20 | "objectLiteralShorthandProperties": true, 21 | "octalLiterals": true, 22 | "restParams": true, 23 | "spread": true, 24 | "templateStrings": true, 25 | "unicodeCodePointEscapes": true, 26 | "globalReturn": false, 27 | "jsx": true, 28 | "experimentalObjectRestSpread": true, 29 | } 30 | }, 31 | "rules": { 32 | "indent": ["error", 2], 33 | "semi": [2], 34 | "react/jsx-indent-props": ["error", 2], 35 | "react/jsx-indent": ["error", 2] 36 | }, 37 | 38 | "env": { 39 | "node": true, 40 | "es6": true, 41 | "jest": true, 42 | "jasmine": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/chat/toolbar/OpenSearchIcon.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 5 | import { FaSearch } from 'react-icons/fa'; 6 | 7 | import SearchModal from '../../modals/SearchModal'; 8 | 9 | const OpenSearchIcon = ({ username, messages }) => { 10 | const [showSearchModal, setShowSearchModal] = useState(false); 11 | 12 | const openTooltip = ( 13 | Search Content 14 | ); 15 | 16 | return ( 17 |
    18 | {/* */} 19 | {/* */} 20 | setShowSearchModal(true)} /> 23 | {showSearchModal && setShowSearchModal(false)} />} 28 |
    29 | ); 30 | }; 31 | 32 | OpenSearchIcon.propTypes = { 33 | username: PropTypes.string.isRequired, 34 | messages: PropTypes.array.isRequired, 35 | }; 36 | 37 | export default OpenSearchIcon; 38 | -------------------------------------------------------------------------------- /src/static/sass/_suggestions.scss: -------------------------------------------------------------------------------- 1 | .suggestions-container { 2 | left: 12px; 3 | top: auto; 4 | bottom: 95px; 5 | width: 94%; 6 | border: 1px solid #ccc; 7 | border-bottom: none; 8 | z-index: 100094; 9 | position: absolute; 10 | border-radius: 5px; 11 | background: #fff; 12 | ul { 13 | overflow-y: scroll; 14 | max-height: 40vh; 15 | list-style-type: none; 16 | } 17 | img { 18 | width: 20px; 19 | margin-right: 5px; 20 | } 21 | li { 22 | cursor: pointer; 23 | padding: 3px; 24 | font-size: 15px; 25 | } 26 | li:hover { 27 | background-color: rgba(35, 154, 220, .5); 28 | } 29 | .active { 30 | background-color: rgba(35, 154, 220, .5); 31 | } 32 | ::-webkit-scrollbar-track { 33 | background-color: rgba(128, 128, 128, .5); 34 | } 35 | } 36 | .suggestions-header { 37 | border-bottom: 1px solid #ccc; 38 | padding: 5px 10px 4px; 39 | color: #7F7F83; 40 | font-size: 14px; 41 | background: #FaF8F6; 42 | overflow: hidden; 43 | } 44 | .header-help { 45 | float: right; 46 | } 47 | .inline-margin { 48 | margin: auto 1rem; 49 | } 50 | ::-webkit-scrollbar { 51 | -webkit-appearance: none; 52 | width: 7px; 53 | } 54 | ::-webkit-scrollbar-thumb { 55 | border-radius: 4px; 56 | background-color: rgba(0, 0, 0, .5); 57 | -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, .5); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/layout/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import UserIcon from '../chat/UserIcon'; 6 | import UserList from '../chat/UserList'; 7 | import Logo from './Logo'; 8 | import Settings from './Settings'; 9 | import Info from './Info'; 10 | import { closePicker } from '../../store/actions/chatActions'; 11 | 12 | const Header = ({ 13 | username, 14 | onToggleModalVisibility 15 | }) => { 16 | const [showUserList, setShowUserList] = useState(false); 17 | 18 | const onToggleUserList = () => { 19 | setShowUserList((current) => !current); 20 | }; 21 | 22 | return ( 23 |
    24 |
    25 |
    26 | 27 | 28 |
    29 | 30 |
    31 | 32 | 36 |
    37 | ); 38 | }; 39 | 40 | Header.propTypes = { 41 | username: PropTypes.string.isRequired, 42 | onToggleModalVisibility: PropTypes.func.isRequired 43 | }; 44 | 45 | export default connect(null, { closePicker })(Header); 46 | -------------------------------------------------------------------------------- /src/utils/pagevisibility.js: -------------------------------------------------------------------------------- 1 | // Derived from https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API 2 | 3 | export function detectPageVisible(onVisible, onHidden, onClose){ 4 | // 5 | // If user viewing page versus not 6 | // 7 | 8 | // Set the name of the hidden property and the change event for visibility 9 | var hidden, visibilityChange; 10 | if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support 11 | hidden = "hidden"; 12 | visibilityChange = "visibilitychange"; 13 | } else if (typeof document.msHidden !== "undefined") { 14 | hidden = "msHidden"; 15 | visibilityChange = "msvisibilitychange"; 16 | } else if (typeof document.webkitHidden !== "undefined") { 17 | hidden = "webkitHidden"; 18 | visibilityChange = "webkitvisibilitychange"; 19 | } 20 | 21 | function handleVisibilityChange() { 22 | if (document[hidden]) { 23 | onHidden(); 24 | } else { 25 | onVisible(); 26 | } 27 | } 28 | 29 | document.addEventListener(visibilityChange, handleVisibilityChange, false); 30 | 31 | // 32 | // If user closes tab or window 33 | // 34 | 35 | /* 36 | window.onbeforeunload = function(e) { 37 | onClose(); // Fires in some browsers, but not all 38 | 39 | var msg = 'Do you want to leave this site?'; 40 | e.returnValue = msg; 41 | return msg; 42 | }; 43 | */ 44 | } 45 | -------------------------------------------------------------------------------- /src/components/chat/EmojiSuggestions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { addSuggestion } from '../../store/actions/chatActions'; 4 | import emoji from '../../utils/emoji_convertor'; 5 | import md from '../../utils/link_attr_blank'; 6 | import { scrollIntoViewOptions } from '../../utils/suggestions'; 7 | 8 | const EmojiSuggestions = ({ addSuggestion, chat }) => ( 9 | 28 | ); 29 | 30 | 31 | 32 | export default connect(({ chat }) => ({chat}), { addSuggestion })(EmojiSuggestions); 33 | -------------------------------------------------------------------------------- /src/components/general/AlertContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { Alert } from 'react-bootstrap'; 6 | 7 | import { dismissAlert } from '../../store/actions/alertActions'; 8 | 9 | // https://v4-alpha.getbootstrap.com/components/alerts/#examples 10 | const alertStyles = ['success', 'danger', 'warning', 'info']; 11 | 12 | const AlertContainer = ({ 13 | alertMessage, 14 | alertStyle, 15 | dismissAlert, 16 | alertRenderSeconds, 17 | }) => { 18 | if (!alertStyles.includes(alertStyle)){ 19 | alertStyle = 'success'; 20 | } 21 | 22 | if (alertRenderSeconds && alertRenderSeconds > 0) { 23 | // auto-dismiss option 24 | setTimeout(() => { 25 | dismissAlert(); 26 | }, alertRenderSeconds * 1000); 27 | } 28 | 29 | return ( 30 |
    31 | {alertMessage && 35 | {alertMessage} 36 | } 37 |
    38 | ); 39 | }; 40 | 41 | AlertContainer.propTypes = {}; 42 | 43 | const mapStateToProps = (reduxState) => { 44 | return { 45 | ...reduxState.alert, 46 | }; 47 | }; 48 | 49 | const mapDispatchToProps = (dispatch) => { 50 | return { 51 | dismissAlert: () => dispatch(dismissAlert()), 52 | }; 53 | }; 54 | 55 | export default connect(mapStateToProps, mapDispatchToProps)(AlertContainer); 56 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import { createStore, compose, applyMiddleware } from 'redux'; 6 | import { createEpicMiddleware } from 'redux-observable'; 7 | import rootReducer from './store/reducers'; 8 | import rootEpic from './store/epics'; 9 | 10 | import 'bootstrap/dist/css/bootstrap.css'; 11 | import './utils/vh_fix'; 12 | import './static/sass/main.scss'; 13 | import './static/fonts/Lato.ttf'; 14 | import './static/audio/notification_gertz.wav'; 15 | import './utils/detect_browser'; 16 | import './utils/origin_polyfill'; 17 | import App from './components/App'; 18 | 19 | const composeEnhancers = 20 | typeof window === 'object' && 21 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? 22 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ 23 | // Specify extension’s options like name, actionsBlacklist, actionsCreators, serialize... 24 | }) : compose; 25 | 26 | const epicMiddleware = createEpicMiddleware(rootEpic); 27 | const enhancer = composeEnhancers( 28 | applyMiddleware(epicMiddleware) 29 | ); 30 | 31 | const store = createStore(rootReducer, enhancer); 32 | 33 | if (module.hot) { 34 | module.hot.accept('./reducers', () => 35 | store.replaceReducer(require('./reducers')) 36 | ); 37 | } 38 | 39 | const root = createRoot( 40 | document.getElementById('root') 41 | ); 42 | 43 | root.render( 44 | 45 | 46 | 47 | ); 48 | 49 | -------------------------------------------------------------------------------- /src/store/epics/helpers/createDetectPageVisibilityObservable.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import 'rxjs/add/operator/map'; 3 | import 'rxjs/add/observable/fromEvent'; 4 | import 'rxjs/add/observable/merge'; 5 | import 'rxjs/add/observable/of'; 6 | 7 | export default function createDetectPageVisibilityObservable() { 8 | 9 | if (typeof document === "undefined") { 10 | // make non-web versions a no-op for now 11 | return new Observable((subscriber) => { 12 | subscriber.next(); 13 | subscriber.complete(); 14 | }); 15 | } 16 | 17 | let hiddenKeyName, visibilityChangeEventName; 18 | if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support 19 | hiddenKeyName = "hidden"; 20 | visibilityChangeEventName = "visibilitychange"; 21 | } else if (typeof document.msHidden !== "undefined") { 22 | hiddenKeyName = "msHidden"; 23 | visibilityChangeEventName = "msvisibilitychange"; 24 | } else if (typeof document.webkitHidden !== "undefined") { 25 | hiddenKeyName = "webkitHidden"; 26 | visibilityChangeEventName = "webkitvisibilitychange"; 27 | } 28 | 29 | const detectPageVisibilityObservable = Observable.merge( 30 | Observable.fromEvent(document, visibilityChangeEventName) 31 | .map(() => { 32 | if (document[hiddenKeyName]) { 33 | return 'online'; 34 | } else { 35 | return 'viewing'; 36 | } 37 | }) 38 | ); 39 | 40 | return detectPageVisibilityObservable; 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/suggestions.js: -------------------------------------------------------------------------------- 1 | import { emojiIndex } from 'emoji-mart'; 2 | 3 | export const emojiSuggestions = (cursorStart, value) => { 4 | const input = value.slice(cursorStart + 1); 5 | const emojiSuggestions = emojiIndex.search(input) || []; 6 | const begin = []; 7 | const end = []; 8 | const colonInput = ":" + input; 9 | let suggestion; 10 | for (var i = 0; i < emojiSuggestions.length; i++) { 11 | suggestion = emojiSuggestions[i]; 12 | if (suggestion.colons.startsWith(colonInput)) { 13 | begin.push({name: suggestion.colons}); 14 | } else { 15 | end.push({name: suggestion.colons}); 16 | } 17 | } 18 | begin.sort(sortBySuggest); 19 | end.sort(sortBySuggest); 20 | return begin.concat(end); 21 | }; 22 | 23 | export const mentionSuggestions = (start, value, obj) => { 24 | const users = Object.keys(obj); 25 | const lowerCaseVal = value.slice( start + 1).toLowerCase(); 26 | let filteredMentions = []; 27 | users.forEach((user) => { 28 | const lowerCaseUser = user.toLowerCase(); 29 | if(lowerCaseUser.startsWith(lowerCaseVal)) { 30 | filteredMentions.push({name: '@' + user, status: obj[user]}); 31 | } 32 | }); 33 | return filteredMentions.sort(sortBySuggest); 34 | }; 35 | 36 | const sortBySuggest = (suggest1, suggest2) => { 37 | return suggest1.name.localeCompare(suggest2.name); 38 | }; 39 | 40 | export const scrollIntoViewOptions = {behavior: 'instant', block: 'nearest'}; 41 | if (window.browser === 'Firefox') { 42 | delete(scrollIntoViewOptions.block); 43 | } 44 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestParseMinilockID(t *testing.T) { 13 | // request without header returns Error 14 | req := httptest.NewRequest(http.MethodGet, "/", nil) 15 | 16 | mID, keypair, err := parseMinilockID(req) 17 | 18 | assert.Equal(t, "", mID, fmt.Sprintf("mID was not an empty string: %v\n", mID)) 19 | assert.Nil(t, keypair, fmt.Sprintf("keypair was unexpectedly not nil: %v\n", keypair)) 20 | assert.NotNil(t, err, fmt.Sprintf("err was unexpectedly nil: %v\n", err)) 21 | 22 | // request with invalid header value returns Error 23 | invalidMiniLockId := "2aSQkrU5hp" 24 | req.Header.Set("X-Minilock-Id", invalidMiniLockId) 25 | 26 | mID, keypair, err = parseMinilockID(req) 27 | 28 | assert.Equal(t, "", mID, fmt.Sprintf("mID was not an empty string: %v\n", mID)) 29 | assert.Nil(t, keypair, fmt.Sprintf("keypair was unexpectedly not nil: %v\n", keypair)) 30 | assert.NotNil(t, err, fmt.Sprintf("err was unexpectedly nil: %v\n", err)) 31 | 32 | // request with valid header value returns value, nil Error 33 | validMiniLockId := "s9dDgRKVWvnkpifRXiSgFGj9QLgq1BZ3qvzCsnxPFDrQG" 34 | req.Header.Set("X-Minilock-Id", validMiniLockId) 35 | 36 | mID, keypair, err = parseMinilockID(req) 37 | 38 | assert.Equal(t, validMiniLockId, mID, fmt.Sprintf("mID was unexpected an empty string: %v\n", mID)) 39 | assert.NotNil(t, keypair, fmt.Sprintf("keypair was unexpectedly nil: %v\n", keypair)) 40 | assert.Nil(t, err, fmt.Sprintf("err was unexpectedly not nil: %v\n", err)) 41 | 42 | } 43 | -------------------------------------------------------------------------------- /test/playwright/SearchModal.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const username = "LeapChatUser"; 4 | 5 | const messages = [ 6 | "Hey", 7 | "Baa baa black sheep", 8 | "Have you any wool?", 9 | "Hello", 10 | "Hello Hello", 11 | "Hello Hello Hello", 12 | "Hello!", 13 | "What is up?" 14 | ]; 15 | 16 | test.beforeEach(async ({ page }) => { 17 | await page.goto("http://localhost:8080/"); 18 | await page.locator("#username").fill(username); 19 | await page.getByTestId("set-username").click(); 20 | 21 | for (let i = 0; i < messages.length; i++) { 22 | await page.getByPlaceholder("Enter message").fill(messages[i]); 23 | await page.locator(".message .btn-default").click(); 24 | } 25 | }); 26 | 27 | test.describe("Message History Search Modal", () => { 28 | test("user can open and view the search modal", async ({ page }) => { 29 | await expect(page.locator("#message-search")).not.toBeVisible(); 30 | 31 | await page.locator(".open-message-search").click(); 32 | 33 | await expect(page.locator("#message-search")).toBeVisible(); 34 | }); 35 | 36 | test("user can enter text and view search results", async ({ page }) => { 37 | await page.locator(".open-message-search").click(); 38 | 39 | await page.locator("#message-search").fill("black sheep"); 40 | let resultsList = page.locator(".search-results .chat-message") 41 | await expect(resultsList).toHaveCount(1); 42 | 43 | await page.locator("#message-search").fill("hello"); 44 | resultsList = page.locator(".search-results .chat-message") 45 | await expect(resultsList).toHaveCount(4); 46 | }); 47 | 48 | }); 49 | -------------------------------------------------------------------------------- /test/utils/tags.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { tagByPrefix, cleanedFields } from '../../src/utils/tags'; 4 | 5 | describe('tags', function () { 6 | 7 | describe('#tagByPrefix', function () { 8 | 9 | it('returns first plaintag matching any prefix', function () { 10 | let plaintags = ['chatmessage:hello', 'chatmessage:goodbye']; 11 | let prefixes = ['invalidtype', 'chatmessage']; 12 | let result = tagByPrefix(plaintags, ...prefixes); 13 | expect(result).to.equal(plaintags[0]); 14 | 15 | plaintags = ['invalidtype:something', 'picture:bytes'] 16 | prefixes = ['chatmessage', 'picture']; 17 | result = tagByPrefix(plaintags, ...prefixes); 18 | expect(result).to.equal(plaintags[1]); 19 | }); 20 | 21 | it('returns empty string if no plaintag matches', function () { 22 | let plaintags = ['chatmessage:hello', 'chatmessage:goodbye']; 23 | let prefixes = ['invalidtype', 'otherinvalidtype']; 24 | let result = tagByPrefix(plaintags, ...prefixes); 25 | expect(result).to.equal(''); 26 | }); 27 | 28 | }); 29 | 30 | describe('#cleanedFields', function () { 31 | it('converts empty string to empty array', function () { 32 | let result = cleanedFields(''); 33 | expect(result).to.deep.equal([]); 34 | }); 35 | 36 | it('converts string to array', function () { 37 | let result = cleanedFields(' hello,there '); 38 | expect(result).to.deep.equal(['hello', 'there']); 39 | }); 40 | 41 | it('converts string with multi-whitespace separators to array', function () { 42 | let result = cleanedFields(' hello, there world '); 43 | expect(result).to.deep.equal(['hello', 'there', 'world']); 44 | }); 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/general/Throbber.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | const throbberDotStyles = { 5 | 'margin': '10px', 6 | 'height': '20px', 7 | 'width': '20px', 8 | 'borderRadius': '10px', 9 | 'backgroundColor': '#999', 10 | 'float': 'left', 11 | 'transform': 'scale(0.7)' 12 | }; 13 | 14 | 15 | class Throbber extends Component { 16 | componentDidMount(){ 17 | 18 | this.animateLoadingDots(); 19 | 20 | } 21 | 22 | // this is messy, just a test animating react elements 23 | // by their ref directly. 24 | animateLoadingDots(){ 25 | let keyframes = [ 26 | { 'transform': 'scale(0.7)' }, 27 | { 'transform': 'scale(1.0)' }, 28 | { 'transform': 'scale(0.7)' }, 29 | ]; 30 | 31 | let properties = { 32 | duration: 1000, 33 | iterations: Infinity 34 | }; 35 | 36 | let firstDot = ReactDOM.findDOMNode(this.refs.firstDot); 37 | firstDot.animate(keyframes, properties); 38 | 39 | properties['delay'] = 200; 40 | 41 | let secondDot = ReactDOM.findDOMNode(this.refs.secondDot); 42 | secondDot.animate(keyframes, properties); 43 | 44 | properties['delay'] = 400; 45 | 46 | let thirdDot = ReactDOM.findDOMNode(this.refs.thirdDot); 47 | thirdDot.animate(keyframes, properties); 48 | } 49 | 50 | render(){ 51 | return ( 52 |
    53 |
    54 |
    55 |
    56 |
    57 |
    58 |
    59 | ); 60 | } 61 | } 62 | 63 | export default Throbber; 64 | -------------------------------------------------------------------------------- /src/components/chat/MessageBox.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import MessageList from './MessageList'; 4 | import { connect } from 'react-redux'; 5 | import { closePicker } from '../../store/actions/chatActions'; 6 | import { playNotification } from '../../utils/audio'; 7 | 8 | class MessageBox extends Component { 9 | constructor(props){ 10 | super(props); 11 | 12 | this.messagesEnd = null; 13 | 14 | this.state = { 15 | notifiedIds: [] 16 | }; 17 | } 18 | 19 | onNewMessages = () => { 20 | if (this.props.isAudioEnabled){ 21 | playNotification(); 22 | } 23 | this.scrollToBottom(); 24 | }; 25 | 26 | scrollToBottom = () => { 27 | this.messagesEnd && this.messagesEnd.scrollIntoView(); 28 | }; 29 | 30 | // Separate function to set reference because of https://reactjs.org/docs/refs-and-the-dom.html#caveats 31 | setMessagesEndRef = (element) => { 32 | this.messagesEnd = element; 33 | }; 34 | 35 | render(){ 36 | let { messages, username, closePicker } = this.props; 37 | 38 | return ( 39 |
    40 |
    41 | this.onNewMessages()} 43 | messages={messages} 44 | username={username} /> 45 |
    46 |
    48 |
    49 |
    50 | ); 51 | } 52 | } 53 | 54 | 55 | const mapStateToProps = (reduxState) => { 56 | return { 57 | messages: reduxState.chat.messages, 58 | username: reduxState.chat.username, 59 | isAudioEnabled: reduxState.settings.isAudioEnabled, 60 | }; 61 | }; 62 | 63 | export default connect(mapStateToProps, { closePicker })(MessageBox); 64 | -------------------------------------------------------------------------------- /test/playwright/SetUsername.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { MAX_USERNAME_LENGTH } from '../../src/components/modals/Username'; 4 | 5 | const username = "LeapChatUser"; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await page.goto("http://localhost:8080/"); 9 | }); 10 | 11 | test.describe('Sets an initial user name', () => { 12 | test('sets username', async ({ page }) => { 13 | await expect(page.locator(".form-group .alert-success")).toBeVisible(); 14 | await expect(page.locator(".form-group .alert-success")).toHaveText(/New room created/); 15 | 16 | await page.locator("#username").fill(username); 17 | await page.getByTestId("set-username").click(); 18 | 19 | await expect(page.locator("#username")).not.toBeVisible(); 20 | 21 | await expect(page.locator(".users-list")).toContainText(username); 22 | }); 23 | 24 | test("sees an error if username is empty", async ({ page }) => { 25 | await expect(page.locator(".alert-danger")).not.toBeVisible(); 26 | 27 | await page.locator("#username").fill(""); 28 | await page.getByTestId("set-username").click(); 29 | 30 | await expect(page.locator(".form-group .alert-danger")).toBeVisible(); 31 | await expect(page.locator(".form-group .alert-danger")).toHaveText(/Must not be empty/); 32 | }); 33 | 34 | test("sees an error if username is too long", async ({ page }) => { 35 | await expect(page.locator(".alert-danger")).not.toBeVisible(); 36 | 37 | const tooLongUsername = new Array(MAX_USERNAME_LENGTH + 3).join("X"); 38 | 39 | await page.locator("#username").fill(tooLongUsername); 40 | await page.getByTestId("set-username").click(); 41 | 42 | await expect(page.locator(".alert-danger")).toBeVisible(); 43 | const expectedError = new RegExp(`Length must not exceed ${MAX_USERNAME_LENGTH}`); 44 | await expect(page.locator(".alert-danger")).toHaveText(expectedError); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/modals/SettingsModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Modal, Button, OverlayTrigger, Tooltip } from 'react-bootstrap'; 5 | import { FaExternalLinkAlt } from 'react-icons/fa'; 6 | 7 | import { chatHandler } from '../../store/epics/chatEpics'; 8 | 9 | const onDeleteAllMsgs = (e) => { 10 | if (window.confirm("Are you sure you want to delete every existing chat message from this chat room? This action cannot be undone.")) { 11 | chatHandler.sendDeleteAllMessagesSignalToServer(); 12 | } 13 | }; 14 | 15 | const SettingsModal = ({ 16 | isVisible, 17 | onClose 18 | }) => { 19 | 20 | return ( 21 |
    22 | 23 | 24 | Settings 25 | 26 | 27 |

    Feedback

    28 |

    29 | Do you have feedback or suggestions on how we can improve LeapChat? We're listening!{' '} 30 | 31 | Share your feedback here.{' '} 32 | 33 |

    34 |
    35 |

    Danger Zone

    36 |

    37 | By clicking here, you will delete all messages in this chat from the server, for all users, forever. 38 |

    39 | 42 | 43 |
    44 | 45 | 46 | 47 |
    48 |
    49 | ); 50 | }; 51 | 52 | SettingsModal.propTypes = { 53 | isVisible: PropTypes.bool.isRequired, 54 | onClose: PropTypes.func.isRequired, 55 | 56 | }; 57 | 58 | export default SettingsModal; -------------------------------------------------------------------------------- /test/playwright/InviteUsers.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const username = "LeapChatUser"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto("http://localhost:8080/"); 7 | await page.locator("#username").fill(username); 8 | await page.getByTestId("set-username").click(); 9 | }); 10 | 11 | test.describe("Opens sharing modal", () => { 12 | test("opens and closes the invite users modal by clicking gear icon", async ({ page }) => { 13 | await expect(page.getByText("Invite to Chat")).not.toBeVisible(); 14 | 15 | await page.locator(".sharing").click(); 16 | await expect(page.getByText("Invite to Chat")).toBeVisible(); 17 | 18 | await page.locator(".modal-header .btn-close").click(); 19 | await expect(page.getByText("Invite to Chat")).not.toBeVisible(); 20 | }); 21 | 22 | test("opens and closes the invite users modal by clicking invite button in users list", async ({ page }) => { 23 | await expect(page.getByText("Invite to Chat")).not.toBeVisible(); 24 | 25 | await page.locator(".icon-button").click(); 26 | await expect(page.getByText("Invite to Chat")).toBeVisible(); 27 | 28 | await page.locator(".modal-header .btn-close").click(); 29 | await expect(page.getByText("Invite to Chat")).not.toBeVisible(); 30 | }); 31 | 32 | test("copies invite link to browser clipboard", async ({ page }) => { 33 | await page.locator(".sharing").click(); 34 | await expect(page.getByText("Invite to Chat")).toBeVisible(); 35 | 36 | // in order to check navigator.clipboard.readText(), we need to give playwright 37 | // permissions. Only chromium (not currently running) supports allowing this. 38 | // Just check that we see the tooltip appear. 39 | 40 | await expect(page.getByText("Link copied!")).not.toBeVisible(); 41 | await page.locator(".share-copy-link .icon-button").click(); 42 | await expect(page.getByText("Link copied!")).toBeVisible(); 43 | 44 | }); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/chat/toolbar/ToggleAudioIcon.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 6 | 7 | import { FaVolumeUp } from 'react-icons/fa'; 8 | import { FaVolumeMute } from 'react-icons/fa'; 9 | import { disableAudio, enableAudio } from '../../../store/actions/settingsActions'; 10 | 11 | const disableAudioTooltip = ( 12 | Mute Audio 13 | ); 14 | 15 | const enableAudioTooltip = ( 16 | Enable Audio 17 | ); 18 | 19 | const DisableAudioIcon = ({ onSetIsAudioEnabled }) => ( 20 | // 21 | // 22 | disableAudio()} /> 23 | ); 24 | 25 | const EnableAudioIcon = ({ onSetIsAudioEnabled }) => ( 26 | // 27 | // 28 | enableAudio()} /> 29 | ); 30 | 31 | const ToggleAudioIcon = ({ 32 | isAudioEnabled, 33 | enableAudio, 34 | disableAudio, 35 | }) => { 36 | return ( 37 |
    38 | {isAudioEnabled && disableAudio()} />} 39 | {!isAudioEnabled && enableAudio()} />} 40 |
    41 | ); 42 | }; 43 | 44 | ToggleAudioIcon.propTypes = {}; 45 | 46 | const mapStateToProps = (reduxState) => { 47 | return { 48 | isAudioEnabled: reduxState.settings.isAudioEnabled, 49 | }; 50 | }; 51 | 52 | const mapDispatchToProps = (dispatch) => { 53 | return { 54 | enableAudio: () => dispatch(enableAudio()), 55 | disableAudio: () => dispatch(disableAudio()), 56 | }; 57 | }; 58 | 59 | export default connect(mapStateToProps, mapDispatchToProps)(ToggleAudioIcon); 60 | -------------------------------------------------------------------------------- /src/utils/tags.js: -------------------------------------------------------------------------------- 1 | const utf8 = require('utf8'); 2 | const atob = require('atob'); 3 | const btoa = require('btoa'); 4 | 5 | export function tagByPrefix(plaintags, ...prefixes) { 6 | let prefix = ''; 7 | for (let i = 0; i < prefixes.length; i++) { 8 | prefix = prefixes[i]; 9 | for (let j = 0; j < plaintags.length; j++) { 10 | if (plaintags[j].startsWith(prefix)) { 11 | return plaintags[j]; 12 | } 13 | } 14 | } 15 | return ''; 16 | } 17 | 18 | export function tagByPrefixStripped(plaintags, ...prefixes) { 19 | let tag = ''; 20 | for (let i = 0; i < prefixes.length; i++) { 21 | tag = tagByPrefix(plaintags, prefixes[i]); 22 | if (tag !== '') { 23 | return tag.slice(prefixes[i].length); 24 | } 25 | } 26 | 27 | return ''; 28 | } 29 | 30 | export function tagsByPrefix(plaintags, prefix) { 31 | let tags = []; 32 | for (let i = 0; i < plaintags.length; i++) { 33 | if (plaintags[i].startsWith(prefix)) { 34 | tags.push(plaintags[i]); 35 | } 36 | } 37 | return tags; 38 | } 39 | 40 | export function tagsByPrefixStripped(plaintags, prefix) { 41 | let stripped = []; 42 | for (let i = 0; i < plaintags.length; i++) { 43 | if (plaintags[i].startsWith(prefix)) { 44 | // Strip off prefix 45 | stripped.push(plaintags[i].slice(prefix.length)); 46 | } 47 | } 48 | return stripped; 49 | } 50 | 51 | export function sortRowByCreated(row, nextRow){ 52 | let date = tagByPrefix(row.tags || row.plaintags, 'created:'); 53 | let next = tagByPrefix(nextRow.tags || nextRow.plaintags, 'created:'); 54 | 55 | return date.localeCompare(next); 56 | } 57 | 58 | export function parseJSON(str){ 59 | return JSON.parse(utf8.decode(atob(str.unencrypted))); 60 | } 61 | 62 | export function encodeObjForPost(obj){ 63 | return btoa(utf8.encode(JSON.stringify(obj))); 64 | } 65 | 66 | export function cleanedFields(s){ 67 | let fields = s.trim().replace(',', ' ').split(/\s+/g); 68 | return fields.filter(f => f !== ''); 69 | } 70 | -------------------------------------------------------------------------------- /leapchat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "strings" 6 | 7 | "github.com/cryptag/go-minilock/taber" 8 | "github.com/cryptag/leapchat/miniware" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var ( 13 | randomServerKey *taber.Keys 14 | BUILD_DIR = "build" 15 | ) 16 | 17 | func init() { 18 | k, err := taber.RandomKey() 19 | if err != nil { 20 | log.Fatalf("Error generating random server key: %v\n", err) 21 | } 22 | 23 | // Setting global var 24 | randomServerKey = k 25 | } 26 | 27 | func main() { 28 | httpAddr := flag.String("http", "127.0.0.1:8080", 29 | "Address to listen on HTTP") 30 | httpsAddr := flag.String("https", "127.0.0.1:8443", 31 | "Address to listen on HTTPS") 32 | domain := flag.String("domain", "", "Domain of this service") 33 | iframeOrigin := flag.String("iframe-origin", "", 34 | "Origin that may embed this LeapChat instance into an iframe."+ 35 | " May include port. Only used with -prod flag.") 36 | prod := flag.Bool("prod", false, "Run in Production mode.") 37 | onionPush := flag.Bool("onionpush", false, "Serve OnionPush instead of LeapChat") 38 | flag.Parse() 39 | 40 | if *onionPush { 41 | *httpAddr = "127.0.0.1:5001" 42 | BUILD_DIR = "public" 43 | } 44 | 45 | if *prod { 46 | log.SetLevel(log.FatalLevel) 47 | } else { 48 | log.SetLevel(log.DebugLevel) 49 | } 50 | 51 | m := miniware.NewMapper() 52 | 53 | srv := NewServer(m, *httpAddr) 54 | 55 | if *prod { 56 | if *domain == "" { 57 | log.Fatal("You must specify a -domain when using the -prod flag.") 58 | } 59 | 60 | manager := getAutocertManager(*domain) 61 | 62 | // Setup http->https redirection 63 | httpsPort := strings.SplitN(*httpsAddr, ":", 2)[1] 64 | go redirectToHTTPS(*httpAddr, httpsPort, *domain, manager) 65 | 66 | // Production modifications to server 67 | ProductionServer(srv, *httpsAddr, *domain, manager, *iframeOrigin) 68 | log.Infof("Listening on %v", *httpsAddr) 69 | log.Fatal(srv.ListenAndServeTLS("", "")) 70 | } else { 71 | log.Infof("Listening on %v", *httpAddr) 72 | log.Fatal(srv.ListenAndServe()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/chat/Message.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import emoji from '../../utils/emoji_convertor'; 3 | import md from '../../utils/link_attr_blank'; 4 | 5 | class Message extends Component { 6 | 7 | render() { 8 | let { message, username } = this.props; 9 | let fromMe = message.from === username; 10 | let messageClass = fromMe ? 'chat-outgoing' : 'chat-incoming'; 11 | 12 | let emojified = emoji.replace_colons(message.msg); 13 | 14 | // Convert `emoji.replace_colons`-generated tags to Markdown 15 | let emojiMD = emojified.replace( 16 | /<\/span>/g, 17 | (match, $1, $2, $3) => { 18 | // Example: 19 | // 20 | // $1 == /static/img/emoji/apple/64/ 21 | // $2 == 1f604 22 | // $3 == .png 23 | // emoji.data[$2][3][0] == smile 24 | // return '![:smile:](/static/img/emoji/apple/64/1f604.png)' 25 | let emojiName = 'emoji'; 26 | let emojiNameArray = null; 27 | 28 | // Sometimes $2 looks something like 1f604-1f604-1f604-1f604 29 | const parts = ($2).split('-'); 30 | const partsLength = parts.length; 31 | for (let i = partsLength; i > 0; i--) { 32 | emojiNameArray = emoji.data[parts.slice(0, i).join('-')]; 33 | if (emojiNameArray) { 34 | break; 35 | } 36 | } 37 | 38 | if (emojiNameArray && 39 | emojiNameArray.length >= 4 && 40 | emojiNameArray[3].length >= 1) { 41 | 42 | emojiName = emojiNameArray[3][0]; 43 | } 44 | 45 | return '![:' + emojiName + ':](' + $1 + $2 + $3 + ')'; 46 | } 47 | ); 48 | 49 | // Render escaped HTML/Markdown 50 | let linked = md.render(emojiMD); 51 | 52 | return ( 53 |
  • 54 | {message.from} 55 |
    56 |
    57 |
  • 58 | ); 59 | } 60 | } 61 | 62 | export default Message; 63 | -------------------------------------------------------------------------------- /src/components/chat/UserList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from "react-redux"; 4 | 5 | import { Button } from 'react-bootstrap'; 6 | import { FaShareAlt } from 'react-icons/fa'; 7 | 8 | import { UserStatusIcon } from './UserStatusIcons'; 9 | 10 | import SharingModal from '../modals/SharingModal'; 11 | 12 | const UserList = ({ 13 | username, 14 | statuses, 15 | displayUserList, 16 | onToggleModalVisibility, 17 | }) => { 18 | const [showSharingModal, setShowSharingModal] = useState(false); 19 | const currentUsername = username; 20 | 21 | const userStatuses = Object.keys(statuses).map(username => { 22 | const status = statuses[username]; 23 | return { status, username }; 24 | }); 25 | 26 | const styleUserList = () => { 27 | return { display: displayUserList ? "block" : "none" }; 28 | }; 29 | 30 | return ( 31 |
    32 |
      33 | {userStatuses.map((userStatus, i) => { 34 | return ( 35 |
    • 36 | 41 |
    • 42 | ); 43 | })} 44 |
    45 |
    46 | 52 | {showSharingModal && setShowSharingModal(false)} />} 55 |
    56 | 57 |
    58 | ); 59 | }; 60 | 61 | UserList.propTypes = { 62 | username: PropTypes.string.isRequired, 63 | displayUserList: PropTypes.bool.isRequired, 64 | onToggleModalVisibility: PropTypes.func.isRequired 65 | }; 66 | 67 | const mapStateToProps = (reduxState) => ({ 68 | statuses: reduxState.chat.statuses 69 | }); 70 | 71 | export default connect(mapStateToProps)(UserList); 72 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | const env = require('node-env-file'); 7 | const outputFolder = 'build'; 8 | 9 | const emoji = require('./src/constants/emoji'); 10 | 11 | env(__dirname + '/.env'); 12 | 13 | module.exports = { 14 | context: __dirname, 15 | output: { 16 | path: path.resolve(__dirname, outputFolder), 17 | filename: '[name]_[chunkhash].bundle.js', 18 | clean: true 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.(js|jsx)$/, 24 | exclude: /node_modules/, 25 | loader: 'babel-loader' 26 | }, 27 | { 28 | test: /\.(eot|svg|ttf|woff|woff2)$/, 29 | loader: 'file-loader', 30 | options: { 31 | 'name': '[name].[ext]' 32 | } 33 | }, 34 | { 35 | test: /\.wav$/, 36 | loader: 'file-loader', 37 | options: { 38 | 'name': '[name].[ext]' 39 | } 40 | } 41 | ] 42 | }, 43 | resolve: { 44 | extensions: ['.js', '.jsx', '.css', '.scss', '.json'], 45 | modules: [ 46 | 'node_modules' 47 | ], 48 | fallback: { 49 | "crypto": require.resolve('crypto-browserify'), 50 | "stream": require.resolve("stream-browserify"), 51 | buffer: require.resolve("buffer/"), 52 | } 53 | }, 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | title: 'LeapChat', 57 | template: './src/index-template.ejs' 58 | }), 59 | new webpack.ProvidePlugin({ 60 | Buffer: ['buffer', 'Buffer'], 61 | }), 62 | new MiniCssExtractPlugin({ 63 | filename: '[name].css', 64 | }), 65 | new CopyPlugin([ 66 | { 67 | from: 'node_modules/emoji-datasource-apple/img/apple/64', 68 | to: emoji.EMOJI_APPLE_64_PATH 69 | }, 70 | { 71 | from: 'node_modules/emoji-datasource-apple/img/apple/sheets/64.png', 72 | to: emoji.EMOJI_APPLE_64_SHEET 73 | }, 74 | { 75 | from: 'src/static/js/emoji-fixed.js', 76 | to: '../node_modules/emoji-js/lib/emoji.js' 77 | } 78 | ]), 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /src/components/modals/SharingModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Modal, Button, OverlayTrigger, Tooltip } from 'react-bootstrap'; 5 | import { FaShareAlt } from 'react-icons/fa'; 6 | import { FaShareAltSquare } from 'react-icons/fa'; 7 | 8 | const onCopyShareLink = (e) => { 9 | navigator.clipboard.writeText(window.location.href); 10 | }; 11 | 12 | const onShareLink = (e) => { 13 | navigator.share({ 14 | url: window.location.href, 15 | title: "LeapChat", 16 | text: "Join me on LeapChat" 17 | }); 18 | }; 19 | 20 | const copyLinkTooltip = ( 21 | 22 | Link copied! 23 | 24 | ); 25 | 26 | const SharingModal = ({ 27 | isVisible, 28 | onClose 29 | }) => { 30 | return ( 31 |
    32 | 33 | 34 | Invite to Chat 35 | 36 | 37 | { navigator.share &&
    38 |

    Share Link

    39 |

    40 | Invite with a link shared via SMS, Email, etc. 41 |

    42 | 45 |
    46 |
    47 | } 48 | 49 |

    Copy Link

    50 |

    Invite with a link copied to your clipboard.

    51 |
    52 | 53 | 58 | 62 | 63 |
    64 |
    65 |
    66 |
    67 | ); 68 | }; 69 | 70 | SharingModal.propTypes = { 71 | isVisible: PropTypes.bool.isRequired, 72 | onClose: PropTypes.func.isRequired, 73 | 74 | }; 75 | 76 | export default SharingModal; -------------------------------------------------------------------------------- /src/utils/sessions.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module intended to replace the init connection epic in chatEpics.js 4 | * Currently lacking connection retry capabilities on disconnect. 5 | * STRs: create a chat session in the browser, then kill the running 6 | * leapchat binary, to simulate the server going away. 7 | * The 'disconnected' action can be dispatched on connection error, 8 | * however, this doesn't initiate a proper re-connect flow. 9 | * UNUSED FOR NOW. 10 | */ 11 | import { getEmail, getPassphrase } from '../utils/encrypter'; 12 | import miniLock from '../utils/miniLock'; 13 | 14 | 15 | import { disconnected } from '../store/actions/chatActions'; 16 | 17 | // TODO: will be different host from a mobile device, probably if (!window) 18 | const authUrl = `${window.location.origin}/api/login`; 19 | 20 | 21 | async function connectWithAuthRequest(initiateConnection, mID, secretKey, isNewPassphrase) { 22 | const response = await fetch(authUrl, { 23 | method: "GET", 24 | headers: { 25 | 'X-Minilock-Id': mID 26 | } 27 | }); 28 | 29 | const message = await response.blob(); 30 | miniLock.crypto.decryptFile(message, mID, secretKey, 31 | function (fileBlob, saveName, senderID) { 32 | const reader = new FileReader(); 33 | reader.addEventListener("loadend", () => { 34 | const authToken = reader.result; 35 | initiateConnection({ 36 | authToken, 37 | secretKey, 38 | mID, 39 | isNewRoom: isNewPassphrase, 40 | }); 41 | }); 42 | 43 | reader.readAsText(fileBlob); 44 | }); 45 | } 46 | 47 | export const initiateSessionAndConnect = ( 48 | initiateConnection, 49 | createWebSession, 50 | urlHash, 51 | ) => { 52 | // 1. Get passphrase 53 | const { 54 | passphrase, 55 | isNewPassphrase 56 | } = getPassphrase(urlHash); 57 | 58 | // 2. Get email based on passphrase 59 | let email = getEmail(passphrase); 60 | // 3. Decrypt to get key pair 61 | miniLock.crypto.getKeyPair(passphrase, email, async (keyPair) => { 62 | miniLock.session.keys = keyPair; 63 | miniLock.session.keyPairReady = true; 64 | let mID = miniLock.crypto.getMiniLockID(keyPair.publicKey); 65 | // 4. When we have keypair, create session on device 66 | if (isNewPassphrase) { 67 | await createWebSession(passphrase); 68 | } 69 | 70 | // 5. Initiate connection by doing auth dance 71 | connectWithAuthRequest(initiateConnection, mID, keyPair.secretKey, isNewPassphrase); 72 | }); 73 | }; -------------------------------------------------------------------------------- /room_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRoomManager(t *testing.T) { 12 | rooms := NewRoomManager(pgClient) 13 | roomIDs := []string{} 14 | wg := &sync.WaitGroup{} 15 | 16 | for i := 0; i < 10; i++ { 17 | roomID := fmt.Sprintf("room-%d", i) 18 | roomIDs = append(roomIDs, roomID) 19 | 20 | wg.Add(1) 21 | go func(roomID string) { 22 | rooms.GetRoom(roomID) 23 | wg.Done() 24 | }(roomID) 25 | } 26 | 27 | wg.Wait() 28 | for _, roomID := range roomIDs { 29 | assert.NotNil(t, rooms.rooms[roomID], fmt.Sprintf("RoomID: %v\nRooms: %v\n", roomID, rooms)) 30 | } 31 | } 32 | 33 | func TestRoomMessages(t *testing.T) { 34 | r := NewRoom("testing-room-testing-room-testing-room-testing", pgClient) 35 | wg := &sync.WaitGroup{} 36 | 37 | msgs := [][]Message{} 38 | expectedMsgs := []Message{} 39 | for i := 0; i < 10; i++ { 40 | msgs = append(msgs, []Message{}) 41 | for n := 0; n < 10; n++ { 42 | msgs[i] = append(msgs[i], Message{'a'}) 43 | expectedMsgs = append(expectedMsgs, Message{'a'}) 44 | } 45 | } 46 | 47 | wg.Add(len(msgs) * 2) 48 | ttlSecs := 60 49 | for i := 0; i < 10; i++ { 50 | go func(i int) { 51 | err := r.AddMessages(msgs[i], &ttlSecs) 52 | if err != nil { 53 | t.Logf("Error from AddMessages: %s", err) 54 | 55 | // FALLTHROUGH 56 | } 57 | wg.Done() 58 | }(i) 59 | 60 | go func() { 61 | _, err := r.GetMessages() 62 | if err != nil { 63 | t.Logf("Error from GetMessages: %s", err) 64 | 65 | // FALLTHROUGH 66 | } 67 | wg.Done() 68 | }() 69 | } 70 | 71 | wg.Wait() 72 | 73 | gotMsgs, _ := r.GetMessages() 74 | assert.Equal(t, expectedMsgs, gotMsgs) 75 | } 76 | 77 | func TestRoomClients(t *testing.T) { 78 | r := NewRoom("testing-room-testing-room-testing-room-testing", pgClient) 79 | clients := []*Client{} 80 | wg := &sync.WaitGroup{} 81 | 82 | for i := 0; i < 10; i++ { 83 | client := &Client{} 84 | clients = append(clients, client) 85 | 86 | wg.Add(1) 87 | go func(c *Client) { 88 | r.AddClient(c) 89 | wg.Done() 90 | }(client) 91 | } 92 | 93 | wg.Wait() 94 | assert.Equal(t, clients, r.Clients) 95 | 96 | for _, client := range clients { 97 | wg.Add(1) 98 | go func(c *Client) { 99 | r.RemoveClient(c) 100 | wg.Done() 101 | }(client) 102 | } 103 | 104 | wg.Wait() 105 | assert.Empty(t, r.Clients) 106 | } 107 | 108 | // TODO 109 | func TestRoomBroadcastMessages(t *testing.T) {} 110 | -------------------------------------------------------------------------------- /src/components/chat/UserStatusIcons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Tooltip, OverlayTrigger } from 'react-bootstrap'; 5 | 6 | import { FaCircle } from 'react-icons/fa'; 7 | import { FaMinusCircle } from 'react-icons/fa'; 8 | import { FaEdit } from 'react-icons/fa'; 9 | 10 | const editUsernameTooltip = ( 11 | Edit Username 12 | ); 13 | 14 | export const UserStatusIconBubble = ({ status }) => { 15 | let statusIcon = ; 16 | if (status === 'viewing') { 17 | statusIcon = ; 18 | } else if (status === 'online') { 19 | statusIcon = ; 20 | } 21 | return ( 22 | <> 23 | {statusIcon} 24 | 25 | ); 26 | }; 27 | 28 | UserStatusIconBubble.propTypes = { 29 | status: PropTypes.string.isRequired, 30 | }; 31 | 32 | 33 | export const UserStatusIcon = ({ 34 | username, 35 | status, 36 | isCurrentUser, 37 | onToggleModalVisibility 38 | }) => { 39 | 40 | const onShowUsernameModal = () => { 41 | onToggleModalVisibility('username', true); 42 | }; 43 | 44 | return ( 45 |
    46 | 47 | {username} 48 | {isCurrentUser &&  (me) } 49 | 50 | {isCurrentUser && 51 | // 52 | // 53 | 54 | } 55 | 56 |
    57 | ); 58 | }; 59 | 60 | UserStatusIcon.propTypes = { 61 | username: PropTypes.string.isRequired, 62 | status: PropTypes.string.isRequired, 63 | isCurrentUser: PropTypes.bool.isRequired, 64 | onToggleModalVisibility: PropTypes.func.isRequired 65 | }; 66 | 67 | const styleUserStatus = { 68 | display: 'flex', 69 | flexDirection: 'row', 70 | alignItems: 'center' 71 | }; 72 | 73 | const styleDots = { 74 | marginTop: '.2em', 75 | marginRight: '.2em', 76 | marginBottom: '.2em' 77 | }; 78 | 79 | const styleViewing = Object.assign( 80 | { color: 'green' }, 81 | styleDots 82 | ); 83 | 84 | const styleOnline = Object.assign( 85 | { color: 'yellow' }, 86 | styleDots 87 | ); 88 | 89 | const styleOffline = Object.assign( 90 | { color: 'gray' }, 91 | styleDots 92 | ); 93 | 94 | const styleEditUsername = { 95 | cursor: 'pointer', 96 | marginLeft: 'auto', 97 | marginRight: '2px' // For optical vertical alignment with gear icon 98 | }; -------------------------------------------------------------------------------- /src/components/modals/PincodeModal.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Modal, Button } from 'react-bootstrap'; 5 | 6 | import { genPassphrase } from '../../data/minishare'; 7 | 8 | class PincodeModal extends PureComponent { 9 | 10 | componentDidMount() { 11 | this.pincodeInput.focus(); 12 | } 13 | 14 | componentDidUpdate(){ 15 | if(this.props.showModal){ 16 | this.pincodeInput.focus(); 17 | } 18 | } 19 | 20 | onPincodeKeyPress = (e) => { 21 | if (e.which === 13) { 22 | this.onSetPincodeClick(); 23 | } 24 | }; 25 | 26 | isPincodeValid(pincode) { 27 | if (!pincode || pincode.endsWith("--")) { 28 | return false; 29 | } 30 | return true; 31 | } 32 | 33 | onSetPincodeClick = (e) => { 34 | const pincode = this.pincodeInput.value; 35 | 36 | if (!this.isPincodeValid(pincode)) { 37 | alert('Invalid pincode!'); 38 | } else { 39 | this.props.onSetPincode(pincode); 40 | } 41 | }; 42 | 43 | setRandomPincodeInForm = () => { 44 | this.pincodeInput.value = genPassphrase(2); 45 | }; 46 | 47 | onClose = () => { 48 | this.props.onToggleModalVisibility('pincode', false); 49 | }; 50 | 51 | render() { 52 | let { showModal, pincode } = this.props; 53 | 54 | return ( 55 |
    56 | 57 | 58 | Set Pincode 59 | 60 | 61 |
    62 | { this.pincodeInput = input; }} 66 | defaultValue={pincode} 67 | placeholder="Enter pincode or password" 68 | onKeyPress={this.onPincodeKeyPress} 69 | autoFocus={true} /> 70 |
    71 | 72 |
    73 |
    74 | 75 | {pincode && } 76 | 77 | 78 |
    79 |
    80 | ); 81 | } 82 | } 83 | 84 | 85 | PincodeModal.propType = { 86 | showModal: PropTypes.bool.isRequired, 87 | onToggleModalVisibility: PropTypes.func.isRequired, 88 | onSetPincode: PropTypes.func.isRequired 89 | }; 90 | 91 | export default PincodeModal; 92 | -------------------------------------------------------------------------------- /src/store/actions/chatActions.js: -------------------------------------------------------------------------------- 1 | 2 | export const CHAT_INIT_CHAT = 'CHAT_INIT_CHAT'; 3 | export const CHAT_INIT_CONNECTION = 'CHAT_INIT_CONNECTION'; 4 | export const CHAT_DISCONNECTED = 'CHAT_DISCONNECTED'; 5 | export const CHAT_CONNECTION_INITIATED = 'CHAT_CONNECTION_INITIATED'; 6 | export const CHAT_SEND_MESSAGE = 'CHAT_SEND_MESSAGE'; 7 | export const CHAT_MESSAGE_SENT = 'CHAT_MESSAGE_SENT'; 8 | export const CHAT_ADD_MESSAGE = 'CHAT_ADD_MESSAGE'; 9 | export const CHAT_SET_USER_STATUS = 'CHAT_SET_USER_STATUS'; 10 | export const CHAT_USER_STATUS_SENT = 'CHAT_USER_STATUS_SENT'; 11 | export const CHAT_SET_USERNAME = 'CHAT_SET_USERNAME'; 12 | export const CHAT_USERNAME_SET = 'CHAT_USERNAME_SET'; 13 | 14 | export const initChat = () => ({ type: CHAT_INIT_CHAT }); 15 | 16 | export const initConnection = (createDeviceSession, urlHash ) => 17 | ({ type: CHAT_INIT_CONNECTION, createDeviceSession, urlHash }); 18 | 19 | export const disconnected = () => 20 | ({ type: CHAT_DISCONNECTED }); 21 | 22 | export const connectionInitiated = () => 23 | ({ type: CHAT_CONNECTION_INITIATED }); 24 | 25 | export const sendMessage = ({ message, username }) => 26 | ({ type: CHAT_SEND_MESSAGE, message, username }); 27 | 28 | export const messageSent = () => 29 | ({ type: CHAT_MESSAGE_SENT }); 30 | 31 | export const addMessage = ({ key, fromUsername, maybeSenderId, message }) => 32 | ({ type: CHAT_ADD_MESSAGE, key, fromUsername, maybeSenderId, message }); 33 | 34 | export const setUserStatus = ({ username, status }) => 35 | ({ type: CHAT_SET_USER_STATUS, username, status }); 36 | 37 | export const userStatusSent = () => 38 | ({ type: CHAT_USER_STATUS_SENT }); 39 | 40 | export const setUsername = (username) => 41 | ({ type: CHAT_SET_USERNAME, username }); 42 | 43 | export const usernameSet = (username) => 44 | ({ type: CHAT_USERNAME_SET, username }); 45 | 46 | export const messageUpdate = (message) => 47 | ({ type: 'CHAT_MESSAGE_UPDATE', message: message }); 48 | 49 | export const clearMessage = () => 50 | ({ type: 'CHAT_MESSAGE_CLEAR' }); 51 | 52 | export const togglePicker = () => 53 | ({ type: 'CHAT_TOGGLE_PICKER' }); 54 | 55 | export const addEmoji = (emoji, selectionStart) => 56 | ({ type: 'CHAT_ADD_EMOJI', emoji, selectionStart }); 57 | 58 | export const closePicker = () => 59 | ({ type: 'CHAT_CLOSE_PICKER' }); 60 | 61 | export const startSuggestions = (cursorIndex, filterSuggestions, list) => 62 | ({ type: 'CHAT_START_SUGGESTIONS', cursorIndex, filterSuggestions, list }); 63 | 64 | export const showSuggestions = (cursorIndex, value, filterSuggestions, list) => 65 | ({ type: 'CHAT_SHOW_SUGGESTIONS', suggestions: filterSuggestions(cursorIndex, value, list) }); 66 | 67 | export const addSuggestion = (suggestion) => 68 | ({ type: 'CHAT_ADD_SUGGESTION', suggestion }); 69 | 70 | export const stopSuggestions = () => 71 | ({ type: 'CHAT_STOP_SUGGESTIONS' }); 72 | 73 | export const downSuggestion = () => 74 | ({ type: 'CHAT_DOWN_SUGGESTION' }); 75 | 76 | export const upSuggestion = () => 77 | ({ type: 'CHAT_UP_SUGGESTION' }); 78 | -------------------------------------------------------------------------------- /messages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/cryptag/leapchat/miniware" 8 | "github.com/gorilla/websocket" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Message []byte 13 | 14 | type OutgoingPayload struct { 15 | Ephemeral []Message `json:"ephemeral"` 16 | FromServer FromServer `json:"from_server,omitempty"` 17 | } 18 | 19 | type FromServer struct { 20 | AllMessagesDeleted bool `json:"all_messages_deleted,omitempty"` 21 | } 22 | 23 | type ToServer struct { 24 | TTL *int `json:"ttl_secs"` 25 | DeleteAllMessages bool `json:"delete_all_messages"` 26 | } 27 | 28 | type IncomingPayload struct { 29 | Ephemeral []Message `json:"ephemeral"` 30 | ToServer ToServer `json:"to_server"` 31 | } 32 | 33 | func WSMessagesHandler(rooms *RoomManager) func(w http.ResponseWriter, r *http.Request) { 34 | return func(w http.ResponseWriter, r *http.Request) { 35 | // Both guaranteed by middleware 36 | wsConn, _ := miniware.GetWebsocketConn(r) 37 | roomID, _ := miniware.GetMinilockID(r) 38 | 39 | room := rooms.GetRoom(roomID) 40 | 41 | client := &Client{ 42 | wsConn: wsConn, 43 | room: room, 44 | } 45 | room.AddClient(client) 46 | 47 | go messageReader(room, client) 48 | } 49 | } 50 | 51 | func messageReader(room *Room, client *Client) { 52 | msgs, err := room.GetMessages() 53 | if err != nil { 54 | client.SendError(err.Error(), err) 55 | return 56 | } 57 | 58 | // Send them already-existing messages 59 | err = client.SendMessages(msgs...) 60 | if err != nil { 61 | client.SendError(err.Error(), err) 62 | return 63 | } 64 | 65 | for { 66 | messageType, p, err := client.wsConn.ReadMessage() 67 | if err != nil { 68 | // TODO: Consider adding more checks 69 | log.Debugf("Error reading ws message: %s", err) 70 | room.RemoveClient(client) 71 | return 72 | } 73 | 74 | // Respond to message depending on message type 75 | switch messageType { 76 | case websocket.TextMessage: 77 | var payload IncomingPayload 78 | err := json.Unmarshal(p, &payload) 79 | if err != nil { 80 | log.Debugf("Error unmarshalling message `%s` -- %s", p, err) 81 | continue 82 | } 83 | 84 | if payload.ToServer.DeleteAllMessages { 85 | err = room.DeleteAllMessages() 86 | if err != nil { 87 | room.BroadcastMessages(client, Message(err.Error())) 88 | log.Errorf("Error deleting all messages from room %s -- %s", room.ID, err) 89 | continue 90 | } 91 | room.BroadcastDeleteSignal() 92 | continue 93 | } 94 | 95 | err = room.AddMessages(payload.Ephemeral, payload.ToServer.TTL) 96 | if err != nil { 97 | log.Debugf("Error from AddMessages: %v", err) 98 | continue 99 | } 100 | 101 | room.BroadcastMessages(client, payload.Ephemeral...) 102 | 103 | case websocket.BinaryMessage: 104 | log.Debug("Binary messages are unsupported") 105 | 106 | case websocket.CloseMessage: 107 | log.Debug("Got close message") 108 | room.RemoveClient(client) 109 | return 110 | 111 | default: 112 | log.Debugf("Unsupport messageType: %d", messageType) 113 | 114 | } 115 | 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/modals/InfoModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Modal } from 'react-bootstrap'; 4 | 5 | const InfoModal = ({ isVisible, onClose }) => { 6 | 7 | return ( 8 | 9 | 10 | 11 |

    Welcome to LeapChat!

    12 |
    13 |
    14 | 15 |

    LeapChat: encrypted, ephemeral, in-browser chat.

    16 |

    Just visit leapchat.org and a new, secure chat room will instantly be created for you. And once you're in, just link people to that page to invite them to join you!

    17 |

    18 | Why LeapChat? 19 |

    20 |

    21 | You shouldn't have to sacrifice your privacy and personal information just to chat online. Slack, HipChat, and others make you create an account with your email address, their software doesn't encrypt your messages (they can see everything), and the messages last forever unless you manually delete them. 22 | In contrast, LeapChat does encrypt your messages (even we can't see them!), doesn't require you to hand over your email address, and messages last for a maximum of 90 days (this will soon be configurable to a shorter duration). 23 | Plus, you can host LeapChat on your own server, since it's open source! 24 |

    25 |

    26 | How does it work? 27 |

    28 |
    29 | When you click on a link to a LeapChat room: 30 |
      31 |
    1. 32 | Your browser loads the HTML, CSS, and JavaScript from the server (e.g., leapchat.org) 33 |
    2. 34 |
    3. 35 | That JavaScript code then grabs the long passphrase at the end of the URL (the "URL hash" -- everything after the `#`), then passes it to miniLock, which then deterministically generates a keypair from that passphrase 36 |
    4. 37 |
    5. 38 | That cryptographic keypair is then used by your browser (and every other chat participant) to encrypt and decrypt messages to and from the people you're chatting with 39 |
    6. 40 |
    41 | The server can't even see your username! That's encrypted, too, and is attached to the messages you send. 42 |
    43 |

    44 | Can I type markdown in my messages? 45 |

    46 |

    47 | Yup! To learn about Markdown syntax, like surrounding words with **double asterisks** to make them bold, or with _underscores_ to make them italicized, check out this guide. 48 |

    49 |
    50 |
    51 | ); 52 | }; 53 | 54 | InfoModal.propTypes = { 55 | onClose: PropTypes.func.isRequired 56 | }; 57 | 58 | export default InfoModal; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LeapChat", 3 | "version": "0.7.8", 4 | "description": "Self-destructing, encrypted, in-browser chat", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "./node_modules/.bin/eslint ./src", 8 | "build": "node_modules/.bin/webpack --config webpack.config.prod.js", 9 | "start": "npm run build && ./leapchat", 10 | "dev": "node_modules/.bin/webpack --config webpack.config.dev.js --progress", 11 | "be": "./leapchat", 12 | "mocha": "./node_modules/.bin/mocha --reporter nyan test/.setup.js test/**/*.test.js", 13 | "playwright": "npx playwright test", 14 | "webtest": "npx playwright test --headed", 15 | "test": "npm run mocha && npm run playwright", 16 | "lintfix": "./node_modules/.bin/eslint --fix --parser @babel/eslint-parser --ext js --no-eslintrc --rule 'indent: [\"error\", 2]' --rule 'semi: [2]' ./src" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/cryptag/leapchat.git" 21 | }, 22 | "keywords": [ 23 | "cryptag", 24 | "messaging", 25 | "chat", 26 | "privacy", 27 | "security", 28 | "encryption", 29 | "file", 30 | "sharing" 31 | ], 32 | "author": "Steve Phillips ", 33 | "license": "AGPL-3.0", 34 | "bugs": { 35 | "url": "https://github.com/cryptag/leapchat/issues" 36 | }, 37 | "homepage": "https://github.com/cryptag/leapchat#readme", 38 | "dependencies": { 39 | "@babel/preset-env": "^7.20.2", 40 | "@emoji-mart/data": "^1.1.2", 41 | "atob": "^2.1.2", 42 | "babel-loader": "^9.1.2", 43 | "btoa": "^1.2.1", 44 | "crypto-browserify": "^3.12.0", 45 | "emoji-datasource-apple": "^3.0.0", 46 | "emoji-js": "^3.5.0", 47 | "emoji-mart": "^1.0.1", 48 | "guid": "0.0.12", 49 | "jquery": "^3.6.3", 50 | "js-sha512": "^0.8.0", 51 | "markdown-it": "^13.0.1", 52 | "minisearch": "^6.0.1", 53 | "react": "^18.2.0", 54 | "react-bootstrap": "^2.7.2", 55 | "react-dom": "^18.2.0", 56 | "react-icons": "^4.7.1", 57 | "react-redux": "^8.0.5", 58 | "redux": "^4.2.1", 59 | "stream-browserify": "^3.0.0", 60 | "utf8": "^3.0.0" 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.20.12", 64 | "@babel/eslint-parser": "^7.19.1", 65 | "@babel/eslint-plugin": "^7.19.1", 66 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7", 67 | "@babel/preset-react": "^7.18.6", 68 | "@babel/register": "^7.18.9", 69 | "@playwright/test": "^1.30.0", 70 | "@webpack-cli/generators": "^3.0.1", 71 | "babel-plugin-system-import-transformer": "^4.0.0", 72 | "babel-plugin-transform-class-properties": "^6.24.1", 73 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 74 | "blake2s": "^1.1.0", 75 | "bootstrap": "^5.2.3", 76 | "bs58": "^5.0.0", 77 | "buffer": "^6.0.3", 78 | "chai": "^4.3.7", 79 | "copy-webpack-plugin": "^4.6.0", 80 | "css-loader": "^6.7.3", 81 | "eslint": "^8.34.0", 82 | "eslint-plugin-react": "^7.32.2", 83 | "file-loader": "^6.2.0", 84 | "html-webpack-plugin": "^5.5.0", 85 | "jsdom": "^10.1.0", 86 | "mini-css-extract-plugin": "^2.7.2", 87 | "mocha": "^10.2.0", 88 | "node-env-file": "^0.1.8", 89 | "node-sass": "^8.0.0", 90 | "playwright": "^1.30.0", 91 | "prettier": "^2.8.4", 92 | "redux-observable": "^0.19.0", 93 | "rxjs": "^5.5.12", 94 | "sass": "^1.58.0", 95 | "sass-loader": "^13.2.0", 96 | "style-loader": "^3.3.1", 97 | "webpack": "^5.75.0", 98 | "webpack-cli": "^5.0.1", 99 | "webpack-dev-server": "^4.11.1" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/modals/SearchModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Modal, Popover, OverlayTrigger } from 'react-bootstrap'; 5 | import { FaInfoCircle } from 'react-icons/fa'; 6 | 7 | import MiniSearch from 'minisearch'; 8 | 9 | import Message from '../chat/Message'; 10 | 11 | const searchInfoPopover = ( 12 | 13 | Your messages are encrypted in transit and in storage on our servers. So 14 | how can you search them? 15 |

    16 | Simple! The search happens entirely in the browser. So you can rest easy. 17 |
    18 | ); 19 | 20 | 21 | class SearchModal extends Component { 22 | constructor(props) { 23 | super(props); 24 | 25 | const miniSearch = this.indexAllMessages(props.messages); 26 | 27 | this.state = { 28 | username: this.props.username, 29 | isVisible: this.props.isVisible, 30 | miniSearch: miniSearch, 31 | isLoadingResults: false, 32 | searchResults: [] 33 | }; 34 | } 35 | 36 | indexAllMessages(messages) { 37 | const miniSearch = new MiniSearch({ 38 | fields: ['msg', 'from'], // fields to index for full-text search 39 | storeFields: ['msg', 'from'] // fields to return with search results 40 | }); 41 | 42 | messages = this.props.messages.map((message, i) => { 43 | return {"id": i, ...message}; 44 | }); 45 | miniSearch.addAll(messages); 46 | 47 | return miniSearch; 48 | }; 49 | 50 | getSearchResults = (e) => { 51 | this.setState({ 52 | isLoadingResults: true 53 | }); 54 | let searchResults = this.state.miniSearch.search(e.target.value); 55 | this.setState({ 56 | searchResults: searchResults, 57 | isLoadingResults: false 58 | }); 59 | }; 60 | 61 | render() { 62 | const { 63 | isVisible, 64 | searchResults, 65 | isLoadingResults, 66 | username } = this.state; 67 | 68 | searchResults.sort((a, b) => a.score > b.score); 69 | 70 | return ( 71 |
    72 | 73 | 74 | Search{' '} 75 | 76 | {/* */} 77 | {/* */} 78 | 79 | 80 |
    81 |
    82 | 83 | 90 |
    91 |
    92 |
    93 | {isLoadingResults &&
    94 | 95 |

    Loading results...

    96 |
    } 97 | {searchResults.length > 0 &&
    98 |
      99 | {searchResults.map((result, i) => { 100 | return ; 101 | })} 102 |
    103 |
    } 104 | 105 | 106 |
    107 |
    108 |
    109 | ); 110 | } 111 | } 112 | 113 | SearchModal.propTypes = { 114 | username: PropTypes.string.isRequired, 115 | isVisible: PropTypes.bool.isRequired, 116 | onClose: PropTypes.func.isRequired, 117 | messages: PropTypes.array.isRequired, 118 | }; 119 | 120 | export default SearchModal; -------------------------------------------------------------------------------- /pg_types.go: -------------------------------------------------------------------------------- 1 | // Steve Phillips / elimisteve 2 | // 2017.05.18 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "net/http" 13 | "time" 14 | 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | type PGClient struct { 19 | BaseURL string 20 | } 21 | 22 | func NewPGClient(baseURL string) *PGClient { 23 | return &PGClient{ 24 | BaseURL: baseURL, 25 | } 26 | } 27 | 28 | func (cl *PGClient) Post(urlSuffix string, payload interface{}) (*http.Response, error) { 29 | var payloadb []byte 30 | if payload != nil { 31 | b, err := json.Marshal(payload) 32 | if err != nil { 33 | return nil, err 34 | } 35 | payloadb = b 36 | } 37 | 38 | log.Debugf("POST'ing to %s: %s", urlSuffix, payloadb) 39 | 40 | r := bytes.NewReader(payloadb) 41 | req, _ := http.NewRequest("POST", cl.BaseURL+urlSuffix, r) 42 | // req.Header.Add("Prefer", "return=representation") 43 | req.Header.Add("Prefer", "return=none") 44 | req.Header.Add("Content-Type", "application/json") 45 | 46 | return http.DefaultClient.Do(req) 47 | } 48 | 49 | func (cl *PGClient) PostWanted(urlSuffix string, payload interface{}, statusWanted int) error { 50 | resp, err := cl.Post(urlSuffix, payload) 51 | if err != nil { 52 | return err 53 | } 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode != statusWanted { 57 | body, _ := ioutil.ReadAll(resp.Body) 58 | 59 | respjson := map[string]interface{}{} 60 | err = json.Unmarshal(body, &respjson) 61 | return fmt.Errorf( 62 | "Got HTTP %v from PostgREST, wanted %v. Resp: %#v (err unmarshal: %v)", 63 | resp.StatusCode, statusWanted, respjson, err) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func (cl *PGClient) Get(urlSuffix string) (*http.Response, error) { 70 | log.Debugf("GET'ing from %s", urlSuffix) 71 | 72 | req, _ := http.NewRequest("GET", cl.BaseURL+urlSuffix, nil) 73 | // req.Header.Add("Prefer", "return=representation") 74 | req.Header.Add("Prefer", "return=none") 75 | req.Header.Add("Content-Type", "application/json") 76 | 77 | return http.DefaultClient.Do(req) 78 | } 79 | 80 | func (cl *PGClient) Delete(urlSuffix string) (*http.Response, error) { 81 | log.Debugf("DELETE'ing from %s", urlSuffix) 82 | 83 | req, _ := http.NewRequest("DELETE", cl.BaseURL+urlSuffix, nil) 84 | // req.Header.Add("Prefer", "return=representation") 85 | req.Header.Add("Prefer", "return=none") 86 | // req.Header.Add("Content-Type", "application/json") 87 | 88 | return http.DefaultClient.Do(req) 89 | } 90 | 91 | func (cl *PGClient) GetInto(urlSuffix string, respobj interface{}) error { 92 | resp, err := cl.Get(urlSuffix) 93 | if err != nil { 94 | return err 95 | } 96 | defer resp.Body.Close() 97 | 98 | statusWanted := http.StatusOK 99 | 100 | body, _ := ioutil.ReadAll(resp.Body) 101 | 102 | if resp.StatusCode != statusWanted { 103 | respjson := map[string]interface{}{} 104 | err = json.Unmarshal(body, &respjson) 105 | return fmt.Errorf( 106 | "Got HTTP %v from PostgREST, wanted %v. Resp: %#v (err unmarshal: %v)", 107 | resp.StatusCode, statusWanted, respjson, err) 108 | } 109 | 110 | return json.Unmarshal(body, respobj) 111 | } 112 | 113 | type PGRoom struct { 114 | RoomID string `json:"room_id"` 115 | } 116 | 117 | func (room PGRoom) Create(cl *PGClient) error { 118 | return cl.PostWanted("/rooms", room, http.StatusCreated) 119 | } 120 | 121 | type PGMessage struct { 122 | MessageID string `json:"message_id,omitempty"` 123 | RoomID string `json:"room_id"` 124 | Message string `json:"message,omitempty"` 125 | MessageEnc string `json:"message_enc"` 126 | TTL *int `json:"ttl_secs,omitempty"` 127 | Created *time.Time `json:"created,omitempty"` 128 | } 129 | 130 | type pgPostMessage PGMessage 131 | 132 | func (msg *PGMessage) MarshalJSON() ([]byte, error) { 133 | m := pgPostMessage(*msg) 134 | // Store binary data in Postgres base64-encoded 135 | m.MessageEnc = base64.StdEncoding.EncodeToString([]byte(m.MessageEnc)) 136 | return json.Marshal(m) 137 | } 138 | 139 | type PGMessages []*PGMessage 140 | 141 | func (msgs PGMessages) Create(pgClient *PGClient) error { 142 | // TODO: Parse resp.Body as []*PGMessage, return to user 143 | return pgClient.PostWanted("/messages", msgs, http.StatusCreated) 144 | } 145 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { 4 | setUsername, 5 | initConnection, 6 | initChat 7 | } from '../store/actions/chatActions'; 8 | 9 | import Header from './layout/Header'; 10 | 11 | import ChatContainer from './chat/ChatContainer'; 12 | 13 | import PincodeModal from './modals/PincodeModal'; 14 | import UsernameModal from './modals/Username'; 15 | 16 | // evaluate on initial render only, not on every re-render. 17 | const isNewRoom = Boolean(!document.location.hash); 18 | 19 | class App extends Component { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.state = { 24 | modals: { 25 | username: { 26 | isVisible: false 27 | }, 28 | pincode: { 29 | isVisible: false 30 | } 31 | } 32 | }; 33 | 34 | } 35 | 36 | componentDidMount() { 37 | this.props.initChat(); 38 | this.connectIfNeeded(); 39 | } 40 | 41 | componentDidUpdate(prevProps, prevState) { 42 | this.connectIfNeeded(); 43 | } 44 | 45 | connectIfNeeded() { 46 | if (!this.props.pincodeRequired && this.props.shouldConnect){ 47 | this.onInitConnection(); 48 | } 49 | } 50 | 51 | onSetPincode = (pincode = "") => { 52 | if (!pincode || pincode.endsWith("--")) { 53 | this.onError('Invalid pincode!'); 54 | return; 55 | } 56 | this.onInitConnection(pincode); 57 | }; 58 | 59 | createDeviceSession(passphrase) { 60 | document.location.hash = '#' + passphrase; 61 | } 62 | 63 | onInitConnection(pincode='') { 64 | const urlHash = document.location.hash + pincode; 65 | this.props.initConnection(this.createDeviceSession, urlHash); 66 | } 67 | 68 | onToggleModalVisibility = (modalName, isVisible) => { 69 | let modalsState = {...this.state.modals}; 70 | modalsState[modalName].isVisible = isVisible; 71 | this.setState({ 72 | modals: modalsState 73 | }); 74 | }; 75 | 76 | onClosePincodeModal = () => { 77 | this.setState({ 78 | showPincodeModal: false 79 | }); 80 | }; 81 | 82 | render() { 83 | const { 84 | username, 85 | pincodeRequired, 86 | previousUsername, 87 | authenticating, 88 | connecting, 89 | connected, 90 | } = this.props; 91 | 92 | let showUsernameModal = this.state.modals.username.isVisible; 93 | showUsernameModal = !pincodeRequired && (showUsernameModal || username === ''); 94 | 95 | const chatInputFocus = !pincodeRequired && !showUsernameModal && username !== ''; 96 | 97 | return ( 98 |
    99 |
    102 | 103 | {pincodeRequired && } 107 | 108 | {showUsernameModal && } 117 | 118 |
    119 | 120 | 123 | 124 |
    125 | 126 |
    127 | ); 128 | } 129 | } 130 | 131 | App.propTypes = {}; 132 | 133 | const mapStateToProps = (reduxState) => { 134 | return { 135 | username: reduxState.chat.username, 136 | previousUsername: reduxState.chat.previousUsername, 137 | pincodeRequired: reduxState.chat.pincodeRequired, 138 | shouldConnect: reduxState.chat.shouldConnect, 139 | connecting: reduxState.chat.connecting, 140 | connected: reduxState.chat.connected, 141 | }; 142 | }; 143 | 144 | const mapDispatchToProps = (dispatch) => { 145 | return { 146 | initChat: () => dispatch(initChat()), 147 | initConnection: (createDeviceSession, urlHash) => dispatch(initConnection(createDeviceSession, urlHash)), 148 | setUsername: (username) => dispatch(setUsername(username)), 149 | }; 150 | }; 151 | 152 | export default connect(mapStateToProps, mapDispatchToProps)(App); -------------------------------------------------------------------------------- /src/store/reducers/chatReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | CHAT_INIT_CONNECTION, 3 | CHAT_CONNECTION_INITIATED, 4 | CHAT_DISCONNECTED, 5 | CHAT_ADD_MESSAGE, 6 | CHAT_SET_USER_STATUS, 7 | CHAT_USERNAME_SET 8 | } from '../actions/chatActions'; 9 | 10 | import { PARANOID_USERNAME } from '../../constants/messaging'; 11 | 12 | import { 13 | getPersistedUsername, 14 | getIsParanoidMode, 15 | getIsPincodeRequired 16 | } from './helpers/deviceState'; 17 | 18 | const paranoidMode = getIsParanoidMode(); 19 | const pincodeRequired = getIsPincodeRequired(); 20 | const previousUsername = getPersistedUsername(); 21 | 22 | const initialState = { 23 | connecting: false, 24 | connected: false, 25 | shouldConnect: true, 26 | pincodeRequired, 27 | paranoidMode, 28 | username: paranoidMode ? PARANOID_USERNAME : '', 29 | previousUsername: previousUsername, 30 | status: '', 31 | messages: [], 32 | statuses: {}, 33 | message: '', 34 | showEmojiPicker: false, 35 | suggestionStart: null, 36 | suggestions: [], 37 | suggestionWord: '', 38 | highlightedSuggestion: null 39 | }; 40 | 41 | function chatReducer(state = initialState, action) { 42 | 43 | 44 | switch (action.type) { 45 | 46 | case CHAT_INIT_CONNECTION: 47 | return Object.assign({}, state, { 48 | messages: [], 49 | connecting: true, 50 | connected: false, 51 | shouldConnect: false, 52 | pincodeRequired: false, 53 | ready: false 54 | }); 55 | 56 | case CHAT_CONNECTION_INITIATED: 57 | return Object.assign({}, state, { 58 | connecting: false, 59 | connected: true, 60 | shouldConnect: false, 61 | }); 62 | 63 | case CHAT_DISCONNECTED: 64 | return Object.assign({}, state, { 65 | connecting: false, 66 | connected: false, 67 | shouldConnect: true, 68 | ready: false 69 | }); 70 | 71 | case CHAT_ADD_MESSAGE: 72 | return Object.assign({}, state, { 73 | messages: [...state.messages, { 74 | key: action.key, 75 | from: action.fromUsername, 76 | msg: action.message 77 | }], 78 | suggestionStart: null, 79 | suggestions: [] 80 | }); 81 | 82 | case CHAT_SET_USER_STATUS: 83 | return Object.assign({}, state, { 84 | statuses: Object.assign({}, state.statuses, { [action.username]: action.status }) 85 | }); 86 | 87 | case CHAT_USERNAME_SET: 88 | return Object.assign({}, state, { 89 | username: state.paranoidMode ? PARANOID_USERNAME : action.username, 90 | status: 'viewing', 91 | previousUsername: '' 92 | }); 93 | 94 | case 'CHAT_MESSAGE_UPDATE': 95 | return Object.assign({}, state, { 96 | message: action.message 97 | }); 98 | 99 | case 'CHAT_MESSAGE_CLEAR': 100 | return Object.assign({}, state, { 101 | message: '' 102 | }); 103 | 104 | case 'CHAT_TOGGLE_PICKER': 105 | return Object.assign({}, state, { 106 | showEmojiPicker: !state.showEmojiPicker 107 | }); 108 | 109 | case 'CHAT_ADD_EMOJI': 110 | const beforeEmoji = state.message.slice(0, action.selectionStart); 111 | const afterEmoji = state.message.slice(action.selectionStart); 112 | return Object.assign({}, state, { 113 | showEmojiPicker: false, 114 | message: beforeEmoji + action.emoji + ' ' + afterEmoji 115 | }); 116 | 117 | case 'CHAT_CLOSE_PICKER': 118 | return Object.assign({}, state, { 119 | showEmojiPicker: false 120 | }); 121 | 122 | case 'CHAT_START_SUGGESTIONS': 123 | return Object.assign({}, state, { 124 | suggestionStart: action.cursorIndex 125 | }); 126 | 127 | case 'CHAT_SHOW_SUGGESTIONS': 128 | return action.suggestions ? Object.assign({}, state, { 129 | suggestions: action.suggestions.slice(0, 25) || [], 130 | suggestionWord: state.message.slice(state.suggestionStart), 131 | highlightedSuggestion: 0 132 | }) : state; 133 | 134 | case 'CHAT_ADD_SUGGESTION': 135 | const beforeSuggestion = state.message.slice(0, state.suggestionStart); 136 | const afterSuggestion = state.message.slice(state.suggestionStart); 137 | const formattedSuggestion = afterSuggestion.replace(state.suggestionWord, action.suggestion + ' '); 138 | return Object.assign({}, state, { 139 | message: beforeSuggestion + formattedSuggestion, 140 | suggestionWord: action.suggestion 141 | }); 142 | 143 | case 'CHAT_STOP_SUGGESTIONS': 144 | return Object.assign({}, state, { 145 | suggestionStart: null, 146 | suggestions: [] 147 | }); 148 | 149 | case 'CHAT_DOWN_SUGGESTION': 150 | return Object.assign({}, state, { 151 | highlightedSuggestion: state.suggestions[state.highlightedSuggestion + 1] 152 | ? ++state.highlightedSuggestion 153 | : 0 154 | }); 155 | 156 | case 'CHAT_UP_SUGGESTION': 157 | return Object.assign({}, state, { 158 | highlightedSuggestion: state.suggestions[state.highlightedSuggestion - 1] 159 | ? --state.highlightedSuggestion 160 | : state.suggestions.length - 1 161 | }); 162 | 163 | default: 164 | return state; 165 | } 166 | } 167 | 168 | export default chatReducer; 169 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/cryptag/gosecure/canary" 10 | "github.com/cryptag/gosecure/content" 11 | "github.com/cryptag/gosecure/csp" 12 | "github.com/cryptag/gosecure/frame" 13 | "github.com/cryptag/gosecure/hsts" 14 | "github.com/cryptag/gosecure/referrer" 15 | "github.com/cryptag/gosecure/xss" 16 | "github.com/cryptag/leapchat/miniware" 17 | 18 | minilock "github.com/cryptag/go-minilock" 19 | "github.com/cryptag/go-minilock/taber" 20 | "github.com/gorilla/mux" 21 | "github.com/justinas/alice" 22 | uuid "github.com/nu7hatch/gouuid" 23 | log "github.com/sirupsen/logrus" 24 | "golang.org/x/crypto/acme/autocert" 25 | ) 26 | 27 | const ( 28 | MINILOCK_ID_KEY = "minilock_id" 29 | ) 30 | 31 | func NewRouter(m *miniware.Mapper) *mux.Router { 32 | r := mux.NewRouter() 33 | 34 | // pgClient defined in room.go 35 | r.HandleFunc("/api/login", Login(m, pgClient)).Methods("GET") 36 | 37 | msgsHandler := miniware.Auth( 38 | http.HandlerFunc(WSMessagesHandler(AllRooms)), 39 | m, 40 | ) 41 | r.HandleFunc("/api/ws/messages/all", msgsHandler).Methods("GET") 42 | 43 | r.PathPrefix("/").Handler(gzipHandler(http.FileServer(http.Dir("./" + BUILD_DIR)))).Methods("GET") 44 | 45 | http.Handle("/", r) 46 | return r 47 | } 48 | 49 | func NewServer(m *miniware.Mapper, httpAddr string) *http.Server { 50 | r := NewRouter(m) 51 | 52 | return &http.Server{ 53 | Addr: httpAddr, 54 | ReadTimeout: 5 * time.Second, 55 | WriteTimeout: 10 * time.Second, 56 | IdleTimeout: 120 * time.Second, 57 | Handler: r, 58 | } 59 | } 60 | 61 | func ProductionServer(srv *http.Server, httpsAddr, domain string, manager *autocert.Manager, iframeOrigin string) { 62 | gotWarrant := false 63 | middleware := alice.New(canary.GetHandler(&gotWarrant), 64 | csp.GetCustomHandlerStyleUnsafeInline(domain, domain), 65 | hsts.PreloadHandler, frame.GetHandler(iframeOrigin), 66 | content.GetHandler, xss.GetHandler, referrer.NoHandler) 67 | 68 | srv.Handler = middleware.Then(manager.HTTPHandler(srv.Handler)) 69 | 70 | srv.Addr = httpsAddr 71 | srv.TLSConfig = manager.TLSConfig() 72 | } 73 | 74 | func Login(m *miniware.Mapper, pgClient *PGClient) func(w http.ResponseWriter, req *http.Request) { 75 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 76 | mID, keypair, err := parseMinilockID(req) 77 | if err != nil { 78 | WriteErrorStatus(w, "Error: invalid miniLock ID", 79 | err, http.StatusBadRequest) 80 | return 81 | } 82 | 83 | err = PGRoom{RoomID: mID}.Create(pgClient) 84 | if err != nil && !strings.Contains(err.Error(), 85 | "duplicate key value violates unique constraint") { 86 | 87 | WriteErrorStatus(w, "Error creating new room", 88 | err, http.StatusInternalServerError) 89 | return 90 | } 91 | 92 | log.Infof("Login: `%s` is trying to log in\n", mID) 93 | 94 | newUUID, err := uuid.NewV4() 95 | if err != nil { 96 | WriteError(w, "Error generating new auth token; sorry!", err) 97 | return 98 | } 99 | 100 | authToken := newUUID.String() 101 | 102 | err = m.SetMinilockID(authToken, mID) 103 | if err != nil { 104 | WriteError(w, "Error saving new auth token; sorry!", err) 105 | return 106 | } 107 | 108 | filename := "type:authtoken" 109 | contents := []byte(authToken) 110 | sender := randomServerKey 111 | recipient := keypair 112 | 113 | encAuthToken, err := minilock.EncryptFileContents(filename, contents, 114 | sender, recipient) 115 | if err != nil { 116 | WriteError(w, "Error encrypting auth token to you; sorry!", err) 117 | return 118 | } 119 | 120 | w.Write(encAuthToken) 121 | }) 122 | } 123 | 124 | func parseMinilockID(req *http.Request) (string, *taber.Keys, error) { 125 | mID := req.Header.Get("X-Minilock-Id") 126 | 127 | // Validate miniLock ID by trying to generate public key from it 128 | keypair, err := taber.FromID(mID) 129 | if err != nil { 130 | return "", nil, fmt.Errorf("Error validating miniLock ID: %v", err) 131 | } 132 | 133 | return mID, keypair, nil 134 | } 135 | 136 | func redirectToHTTPS(httpAddr, httpsPort, domain string, manager *autocert.Manager) { 137 | srv := &http.Server{ 138 | Addr: httpAddr, 139 | ReadTimeout: 5 * time.Second, 140 | WriteTimeout: 5 * time.Second, 141 | IdleTimeout: 5 * time.Second, 142 | Handler: manager.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 143 | w.Header().Set("Connection", "close") 144 | url := "https://" + domain + ":" + httpsPort + req.URL.String() 145 | if httpsPort == "443" { 146 | url = "https://" + domain + req.URL.String() 147 | } 148 | http.Redirect(w, req, url, http.StatusFound) 149 | })), 150 | } 151 | log.Infof("Listening on %v\n", httpAddr) 152 | log.Fatal(srv.ListenAndServe()) 153 | } 154 | 155 | func getAutocertManager(domain string) *autocert.Manager { 156 | domains := []string{domain} 157 | // Support both website.com and www.website.com 158 | if strings.HasPrefix(domain, "www.") { 159 | domains = append(domains, domain[len("www."):]) 160 | } else { 161 | domains = append(domains, "www." + domain) 162 | } 163 | 164 | return &autocert.Manager{ 165 | Prompt: autocert.AcceptTOS, 166 | HostPolicy: autocert.HostWhitelist(domains...), 167 | Cache: autocert.DirCache("./" + domain), 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/components/modals/Username.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { Modal, Button, Alert, ProgressBar } from 'react-bootstrap'; 6 | 7 | import { generateRandomUsername } from '../../data/username'; 8 | 9 | import { enableAudio } from '../../store/actions/settingsActions'; 10 | 11 | export const MAX_USERNAME_LENGTH = 45; 12 | 13 | const UsernameModal = ({ 14 | isVisible, 15 | isNewRoom, 16 | previousUsername, 17 | username, 18 | onToggleModalVisibility, 19 | setUsername, 20 | enableAudio, 21 | connecting, 22 | connected, 23 | }) => { 24 | const usernameInput = useRef(null); 25 | 26 | const [failMessage, setFailMessage ] = useState(""); 27 | 28 | const onClose = () => { 29 | onToggleModalVisibility('username', false); 30 | }; 31 | 32 | const setRandomUsernameInForm = () => { 33 | usernameInput.current.value = generateRandomUsername(); 34 | usernameInput.current.focus(); 35 | }; 36 | 37 | const isUsernameValid = () => { 38 | const usernameFromForm = usernameInput.current.value; 39 | if (!usernameFromForm || usernameFromForm.length === 0) { 40 | setFailMessage("Must not be empty"); 41 | return false; 42 | } else if (usernameFromForm.length > MAX_USERNAME_LENGTH) { 43 | setFailMessage(`Length must not exceed ${MAX_USERNAME_LENGTH}`); 44 | return false; 45 | } 46 | setFailMessage(""); 47 | return true; 48 | }; 49 | 50 | const onUsernameKeyUp = (e) => { 51 | if (e.which === 13) { 52 | onSetUsername(); 53 | } 54 | }; 55 | 56 | const setDefaultAudio = () => { 57 | // set the audio to the user's previously selected preference; enable by default 58 | let isAudioEnabled = JSON.parse(localStorage.getItem('isAudioEnabled') || 'true'); 59 | if (isAudioEnabled){ 60 | enableAudio(); 61 | } 62 | }; 63 | 64 | const onSetUsername = () => { 65 | if (isUsernameValid()) { 66 | setUsername(usernameInput.current.value); 67 | setDefaultAudio(); 68 | onClose(); 69 | } 70 | }; 71 | 72 | let progress = 0; 73 | let statusMessage = ( 74 |

    Cranking a bunch of gears.

    75 | ); 76 | 77 | if (connecting) { 78 | progress = 50; 79 | statusMessage =

    Creating a secure connection with LeapChat servers.

    ; 80 | } else if (connected) { 81 | progress = 95; 82 | statusMessage =

    Connected!

    ; 83 | } 84 | 85 | return ( 86 |
    87 | 88 | 89 | Set Username 90 | 91 | 92 |
    93 | {/* username is empty on initial page load, not on subsequent 'edit username' opens */} 94 | {!username && isNewRoom && 96 | New room created! 97 | } 98 | {!username && !isNewRoom && 100 | Successfully joined room! 101 | } 102 | 103 | 114 | 115 | {failMessage &&
    116 |
    117 | Invalid Username: 118 | {failMessage} 119 |
    } 120 | 121 |
    122 | {!connected &&
    123 | {statusMessage} 124 | 125 |
    } 126 |
    127 | 128 | {username && } 129 | 130 | 131 |
    132 |
    133 | ); 134 | }; 135 | 136 | UsernameModal.propTypes = { 137 | isVisible: PropTypes.bool.isRequired, 138 | isNewRoom: PropTypes.bool.isRequired, 139 | previousUsername: PropTypes.string.isRequired, 140 | username: PropTypes.string.isRequired, 141 | onToggleModalVisibility: PropTypes.func.isRequired, 142 | setUsername: PropTypes.func.isRequired, 143 | connecting: PropTypes.bool.isRequired, 144 | connected: PropTypes.bool.isRequired, 145 | }; 146 | 147 | const mapDispatchToProps = (dispatch) => { 148 | return { 149 | enableAudio: () => dispatch(enableAudio()), 150 | }; 151 | }; 152 | 153 | export default connect(null, mapDispatchToProps)(UsernameModal); 154 | -------------------------------------------------------------------------------- /miniware/miniware.go: -------------------------------------------------------------------------------- 1 | // Steve Phillips / elimisteve 2 | // 2017.04.01 3 | 4 | package miniware 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "sync" 11 | "time" 12 | 13 | "github.com/cryptag/go-minilock/taber" 14 | gorillacontext "github.com/gorilla/context" 15 | "github.com/gorilla/websocket" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | const ( 20 | MINILOCK_ID_KEY = "minilock_id" 21 | MINILOCK_KEYPAIR_KEY = "minilock_keypair" 22 | WEBSOCKET_CONNECTION = "websocket_connection" 23 | 24 | AuthError = "Error authorizing you" 25 | ) 26 | 27 | var ( 28 | ErrAuthTokenNotFound = errors.New("Auth token not found") 29 | ErrMinilockIDNotFound = errors.New("miniLock ID not found") 30 | ) 31 | 32 | type Mapper struct { 33 | lock sync.RWMutex 34 | m map[string]string // map[authToken]minilockID 35 | } 36 | 37 | func NewMapper() *Mapper { 38 | return &Mapper{m: map[string]string{}} 39 | } 40 | 41 | func (m *Mapper) GetMinilockID(authToken string) (string, error) { 42 | m.lock.RLock() 43 | defer m.lock.RUnlock() 44 | 45 | mID, ok := m.m[authToken] 46 | if !ok { 47 | return "", ErrAuthTokenNotFound 48 | } 49 | return mID, nil 50 | } 51 | 52 | func (m *Mapper) SetMinilockID(authToken, mID string) error { 53 | m.lock.Lock() 54 | defer m.lock.Unlock() 55 | 56 | m.m[authToken] = mID 57 | return nil 58 | } 59 | 60 | var upgrader = websocket.Upgrader{ 61 | ReadBufferSize: 1024, 62 | WriteBufferSize: 1024, 63 | HandshakeTimeout: 45 * time.Second, 64 | CheckOrigin: func(r *http.Request) bool { 65 | origin := r.Header.Get("Origin") 66 | return origin == "http://127.0.0.1:8080" || // dev 67 | origin == "http://localhost:8080" || // dev 68 | origin == "http://10.0.2.2:8080" || // Android emulator 69 | origin == "http://leapchat.org" || // prod 70 | origin == "https://leapchat.org" || // prod 71 | origin == "http://www.leapchat.org" || // prod 72 | origin == "https://www.leapchat.org" || // prod 73 | origin == "" || // CLI 74 | origin == "http://localhost" // Capacitor 75 | }, 76 | } 77 | 78 | func Auth(h http.Handler, m *Mapper) func(w http.ResponseWriter, req *http.Request) { 79 | return func(w http.ResponseWriter, req *http.Request) { 80 | wsConn, err := upgrader.Upgrade(w, req, nil) 81 | if err != nil { 82 | errStr := "Unable to upgrade to websocket conn" 83 | log.Debug(errStr + ": " + err.Error()) 84 | writeError(w, errStr, http.StatusBadRequest) 85 | return 86 | } 87 | 88 | var authToken string 89 | 90 | auth := make(chan interface{}) 91 | 92 | go func() { 93 | messageType, p, err := wsConn.ReadMessage() 94 | if err != nil { 95 | auth <- err 96 | return 97 | } 98 | log.Debugf("Received message of type %v: `%s`", messageType, p) 99 | if messageType != websocket.TextMessage { 100 | auth <- fmt.Errorf("Wanted type %v (TextMessage), got %v", 101 | websocket.TextMessage, messageType) 102 | return 103 | } 104 | auth <- string(p) 105 | }() 106 | 107 | timeout := time.After(5 * time.Second) 108 | select { 109 | case <-timeout: 110 | errStr := "Timed out; didn't send miniLock ID/room ID fast enough" 111 | writeWSError(wsConn, errStr) 112 | return 113 | 114 | case token := <-auth: 115 | switch maybeToken := token.(type) { 116 | case error: 117 | errStr := maybeToken.Error() 118 | writeWSError(wsConn, errStr) 119 | return 120 | 121 | case string: 122 | authToken = maybeToken 123 | 124 | // FALL THROUGH 125 | } 126 | } 127 | 128 | mID, err := m.GetMinilockID(authToken) 129 | if err != nil { 130 | status := http.StatusInternalServerError 131 | if err == ErrAuthTokenNotFound { 132 | status = http.StatusUnauthorized 133 | } 134 | log.Debugf("%v error from GetMinilockID: %v", status, err) 135 | writeWSError(wsConn, AuthError) 136 | return 137 | } 138 | 139 | log.Infof("`%s` just authed successfully; auth token: `%s`\n", mID, 140 | authToken) 141 | 142 | // TODO: Update auth token's TTL/lease to be 1 hour from 143 | // _now_, not just 1 hour since when they first logged in 144 | 145 | keypair, err := taber.FromID(mID) 146 | if err != nil { 147 | log.Debugf("Error from GetMinilockID: %v", err) 148 | writeWSError(wsConn, "Your miniLock ID is invalid?...") 149 | return 150 | } 151 | 152 | gorillacontext.Set(req, MINILOCK_ID_KEY, mID) 153 | gorillacontext.Set(req, MINILOCK_KEYPAIR_KEY, keypair) 154 | gorillacontext.Set(req, WEBSOCKET_CONNECTION, wsConn) 155 | 156 | h.ServeHTTP(w, req) 157 | } 158 | } 159 | 160 | func GetMinilockID(req *http.Request) (string, error) { 161 | mID := gorillacontext.Get(req, MINILOCK_ID_KEY) 162 | mIDStr, ok := mID.(string) 163 | if !ok { 164 | return "", ErrMinilockIDNotFound 165 | } 166 | return mIDStr, nil 167 | } 168 | 169 | func GetWebsocketConn(req *http.Request) (*websocket.Conn, error) { 170 | wsConnInterface := gorillacontext.Get(req, WEBSOCKET_CONNECTION) 171 | wsConn, ok := wsConnInterface.(*websocket.Conn) 172 | if !ok { 173 | return nil, ErrMinilockIDNotFound 174 | } 175 | return wsConn, nil 176 | } 177 | 178 | func writeWSError(wsConn *websocket.Conn, errStr string) error { 179 | log.Debug(errStr) 180 | resp := fmt.Sprintf(`{"error":%q}`, errStr) 181 | err := wsConn.WriteMessage(websocket.TextMessage, []byte(resp)) 182 | wsConn.Close() 183 | return err 184 | } 185 | 186 | func writeError(w http.ResponseWriter, errStr string, statusCode int) { 187 | errJSON := fmt.Sprintf(`{"error":%q}`, errStr) 188 | http.Error(w, errJSON, statusCode) 189 | } 190 | -------------------------------------------------------------------------------- /room.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gorilla/websocket" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const ( 17 | POSTGREST_BASE_URL = "http://localhost:3000" 18 | ) 19 | 20 | var ( 21 | DELETE_EXPIRED_MESSAGES_PERIOD = 10 * time.Minute 22 | 23 | pgClient = NewPGClient(POSTGREST_BASE_URL) 24 | AllRooms = NewRoomManager(pgClient) 25 | ) 26 | 27 | type RoomManager struct { 28 | lock sync.RWMutex 29 | rooms map[string]*Room // map[miniLockID]*Room 30 | 31 | pgClient *PGClient 32 | } 33 | 34 | func NewRoomManager(pgClient *PGClient) *RoomManager { 35 | go func() { 36 | tick := time.Tick(DELETE_EXPIRED_MESSAGES_PERIOD) 37 | var err error 38 | for { 39 | err = pgClient.PostWanted("/rpc/delete_expired_messages", nil, 40 | 204) 41 | if err != nil { 42 | log.Infof("Error deleting expired messages: %v", err) 43 | } else { 44 | log.Debugf("Just deleted expired messages") 45 | } 46 | <-tick 47 | } 48 | }() 49 | return &RoomManager{ 50 | pgClient: pgClient, 51 | rooms: map[string]*Room{}, 52 | } 53 | } 54 | 55 | func (rm *RoomManager) GetRoom(roomID string) *Room { 56 | rm.lock.Lock() 57 | defer rm.lock.Unlock() 58 | 59 | room, ok := rm.rooms[roomID] 60 | if !ok { 61 | room = NewRoom(roomID, rm.pgClient) 62 | rm.rooms[roomID] = room 63 | } 64 | return room 65 | } 66 | 67 | type Room struct { 68 | ID string // miniLock ID 69 | 70 | Clients []*Client 71 | clientLock sync.RWMutex 72 | 73 | pgClient *PGClient 74 | } 75 | 76 | func NewRoom(roomID string, pgClient *PGClient) *Room { 77 | // TODO: Error handling 78 | _ = PGRoom{RoomID: roomID}.Create(pgClient) 79 | return &Room{ 80 | ID: roomID, 81 | pgClient: pgClient, 82 | } 83 | } 84 | 85 | func (r *Room) GetMessages() ([]Message, error) { 86 | var pgMessages PGMessages 87 | err := r.pgClient.GetInto( 88 | "/messages?select=message_enc&order=created.desc&limit=100&room_id=eq."+r.ID, 89 | &pgMessages) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | msgs := make([]Message, len(pgMessages)) 95 | 96 | var bindata []byte 97 | 98 | // Grab just the message_enc field, reverse the order, and turn 99 | // PostgREST's hex string response back into base64 100 | for i := 0; i < len(pgMessages); i++ { 101 | bindata, err = byteaToBytes(pgMessages[i].MessageEnc) 102 | if err != nil { 103 | log.Debugf("Error from byteaToBytes: %s", err) 104 | continue 105 | } 106 | msgs[len(pgMessages)-i-1] = bindata 107 | } 108 | 109 | return msgs, nil 110 | } 111 | 112 | func (r *Room) AddMessages(msgs []Message, ttlSecs *int) error { 113 | post := make(PGMessages, len(msgs)) 114 | 115 | for i := 0; i < len(msgs); i++ { 116 | post[i] = &PGMessage{ 117 | RoomID: r.ID, 118 | MessageEnc: string(msgs[i]), 119 | TTL: ttlSecs, 120 | } 121 | } 122 | 123 | return post.Create(r.pgClient) 124 | } 125 | 126 | func byteaToBytes(hexdata string) ([]byte, error) { 127 | if len(hexdata) <= 2 { 128 | return []byte{}, nil 129 | } 130 | 131 | // Postgres prefixes the stored strings with "\\x" 132 | hexdatab := []byte(hexdata[2:]) 133 | 134 | b64b := make([]byte, hex.DecodedLen(len(hexdatab))) 135 | n, err := hex.Decode(b64b, hexdatab) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | bindata := make([]byte, base64.StdEncoding.DecodedLen(len(b64b))) 141 | n, err = base64.StdEncoding.Decode(bindata, b64b) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | return bindata[:n], nil 147 | } 148 | 149 | func (r *Room) AddClient(c *Client) { 150 | r.clientLock.Lock() 151 | defer r.clientLock.Unlock() 152 | 153 | r.Clients = append(r.Clients, c) 154 | } 155 | 156 | func (r *Room) RemoveClient(c *Client) { 157 | r.clientLock.Lock() 158 | defer r.clientLock.Unlock() 159 | 160 | for i, client := range r.Clients { 161 | if client == c { 162 | r.Clients = append(r.Clients[:i], r.Clients[i+1:]...) 163 | if client.wsConn != nil { 164 | client.wsConn.Close() 165 | } 166 | break 167 | } 168 | } 169 | } 170 | 171 | // If it is a message from the room, make the sender nil. 172 | func (r *Room) BroadcastMessages(sender *Client, msgs ...Message) { 173 | r.clientLock.RLock() 174 | defer r.clientLock.RUnlock() 175 | 176 | for _, client := range r.Clients { 177 | go func(client *Client) { 178 | err := client.SendMessages(msgs...) 179 | if err != nil { 180 | log.Debugf("Error sending message. Err: %s", err) 181 | } 182 | }(client) 183 | } 184 | } 185 | 186 | func (r *Room) DeleteAllMessages() error { 187 | resp, err := r.pgClient.Delete("/messages?room_id=eq." + r.ID) 188 | if err != nil { 189 | return err 190 | } 191 | defer resp.Body.Close() 192 | 193 | body, err := ioutil.ReadAll(resp.Body) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | if len(body) != 0 { 199 | return fmt.Errorf("Error deleting messages: `%s`", body) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | func (r *Room) BroadcastDeleteSignal() { 206 | r.clientLock.RLock() 207 | defer r.clientLock.RUnlock() 208 | 209 | for _, client := range r.Clients { 210 | go func(client *Client) { 211 | err := client.SendDeleteSignal() 212 | if err != nil { 213 | log.Debugf("Error sending message. Err: %s", err) 214 | } 215 | }(client) 216 | } 217 | } 218 | 219 | type Client struct { 220 | wsConn *websocket.Conn 221 | writeLock sync.Mutex 222 | 223 | room *Room 224 | } 225 | 226 | func (c *Client) SendMessages(msgs ...Message) error { 227 | c.writeLock.Lock() 228 | defer c.writeLock.Unlock() 229 | 230 | outgoing := OutgoingPayload{Ephemeral: msgs} 231 | 232 | body, err := json.Marshal(outgoing) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | err = c.wsConn.WriteMessage(websocket.TextMessage, body) 238 | if err != nil { 239 | log.Debugf("Error sending message to client. Removing client from room. Err: %s", err) 240 | c.room.RemoveClient(c) 241 | return err 242 | } 243 | 244 | return nil 245 | } 246 | 247 | func (c *Client) SendDeleteSignal() error { 248 | c.writeLock.Lock() 249 | defer c.writeLock.Unlock() 250 | 251 | outgoing := OutgoingPayload{ 252 | Ephemeral: []Message{}, 253 | FromServer: FromServer{ 254 | AllMessagesDeleted: true, 255 | }, 256 | } 257 | 258 | body, err := json.Marshal(outgoing) 259 | if err != nil { 260 | return err 261 | } 262 | 263 | err = c.wsConn.WriteMessage(websocket.TextMessage, body) 264 | if err != nil { 265 | log.Debugf("Error sending message to client. Removing client from room. Err: %s", err) 266 | c.room.RemoveClient(c) 267 | return err 268 | } 269 | 270 | return nil 271 | } 272 | 273 | func (c *Client) SendError(errStr string, secretErr error) error { 274 | c.writeLock.Lock() 275 | defer c.writeLock.Unlock() 276 | 277 | return WSWriteError(c.wsConn, errStr, secretErr) 278 | } 279 | -------------------------------------------------------------------------------- /src/static/sass/_emojiPicker.scss: -------------------------------------------------------------------------------- 1 | .emoji-mart, 2 | .emoji-mart * { 3 | box-sizing: border-box; 4 | line-height: 1.15; 5 | } 6 | 7 | .emoji-mart { 8 | font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; 9 | font-size: 16px; 10 | display: inline-block; 11 | color: #222427; 12 | border: 1px solid #d9d9d9; 13 | border-radius: 5px; 14 | background: #fff; 15 | position: absolute; 16 | bottom: 95px; 17 | z-index: 94; 18 | } 19 | 20 | .emoji-mart .emoji-mart-emoji { 21 | padding: 6px; 22 | } 23 | 24 | .emoji-mart-bar { 25 | border: 0 solid #d9d9d9; 26 | background-color: #f9f9f9; 27 | } 28 | .emoji-mart-bar:first-child { 29 | border-bottom-width: 1px; 30 | border-top-left-radius: 10px; 31 | border-top-right-radius: 5px; 32 | } 33 | .emoji-mart-bar:last-child { 34 | border-top-width: 1px; 35 | border-bottom-left-radius: 5px; 36 | border-bottom-right-radius: 5px; 37 | } 38 | 39 | .emoji-mart-anchors { 40 | display: flex; 41 | justify-content: space-between; 42 | padding: 0 6px; 43 | color: #858585; 44 | line-height: 0; 45 | } 46 | 47 | .emoji-mart-anchor { 48 | position: relative; 49 | flex: 1; 50 | text-align: center; 51 | padding: 12px 4px; 52 | overflow: hidden; 53 | transition: color .1s ease-out; 54 | } 55 | 56 | .emoji-mart-anchor:hover, 57 | .emoji-mart-anchor-selected { 58 | color: #000000; 59 | } 60 | 61 | .emoji-mart-anchor-selected .emoji-mart-anchor-bar { 62 | bottom: 0; 63 | background-color: #ff0000 !important; 64 | } 65 | 66 | .emoji-mart-anchor-bar { 67 | position: absolute; 68 | bottom: -3px; left: 0; 69 | width: 100%; height: 3px; 70 | } 71 | 72 | .emoji-mart-anchors i { 73 | display: inline-block; 74 | width: 100%; 75 | max-width: 22px; 76 | } 77 | 78 | .emoji-mart-anchors svg { 79 | fill: currentColor; 80 | max-height: 18px; 81 | } 82 | 83 | .emoji-mart-scroll { 84 | overflow-y: scroll; 85 | height: 270px; 86 | padding: 0 6px 6px 6px; 87 | } 88 | 89 | .emoji-mart-search { 90 | margin-top: 6px; 91 | padding: 0 6px; 92 | } 93 | .emoji-mart-search input { 94 | font-size: 16px; 95 | display: block; 96 | width: 100%; 97 | padding: .2em .6em; 98 | border-radius: 25px; 99 | border: 1px solid #d9d9d9; 100 | outline: 0; 101 | } 102 | 103 | .emoji-mart-category .emoji-mart-emoji span { 104 | z-index: 1; 105 | position: relative; 106 | text-align: center; 107 | cursor: pointer; 108 | } 109 | 110 | .emoji-mart-category .emoji-mart-emoji:hover:before { 111 | z-index: 0; 112 | content: ""; 113 | position: absolute; 114 | top: 0; left: 0; 115 | width: 100%; height: 100%; 116 | background-color: #f4f4f4; 117 | border-radius: 100%; 118 | } 119 | 120 | .emoji-mart-category-label { 121 | z-index: 2; 122 | position: relative; 123 | position: -webkit-sticky; 124 | position: sticky; 125 | top: 0; 126 | } 127 | 128 | .emoji-mart-category-label { 129 | display: block; 130 | width: 100%; 131 | font-weight: 500; 132 | padding: 5px 6px; 133 | background-color: #fff; 134 | background-color: rgba(255, 255, 255, .95); 135 | } 136 | 137 | .emoji-mart-emoji { 138 | position: relative; 139 | display: inline-block; 140 | font-size: 0; 141 | cursor: pointer; 142 | } 143 | 144 | .emoji-mart-no-results { 145 | font-size: 14px; 146 | text-align: center; 147 | padding-top: 70px; 148 | color: #858585; 149 | } 150 | .emoji-mart-no-results .emoji-mart-category-label { 151 | display: none; 152 | } 153 | .emoji-mart-no-results .emoji-mart-no-results-label { 154 | margin-top: .2em; 155 | } 156 | .emoji-mart-no-results .emoji-mart-emoji:hover:before { 157 | content: none; 158 | } 159 | 160 | .emoji-mart-preview { 161 | position: relative; 162 | height: 70px; 163 | } 164 | 165 | .emoji-mart-preview-emoji, 166 | .emoji-mart-preview-data, 167 | .emoji-mart-preview-skins { 168 | position: absolute; 169 | top: 50%; 170 | transform: translateY(-50%); 171 | } 172 | 173 | .emoji-mart-preview-emoji { 174 | left: 12px; 175 | } 176 | 177 | .emoji-mart-preview-data { 178 | left: 68px; right: 12px; 179 | word-break: break-all; 180 | } 181 | 182 | .emoji-mart-preview-skins { 183 | right: 30px; 184 | text-align: right; 185 | } 186 | 187 | .emoji-mart-preview-name { 188 | font-size: 14px; 189 | } 190 | 191 | .emoji-mart-preview-shortname { 192 | font-size: 12px; 193 | color: #888; 194 | } 195 | .emoji-mart-preview-shortname + .emoji-mart-preview-shortname, 196 | .emoji-mart-preview-shortname + .emoji-mart-preview-emoticon, 197 | .emoji-mart-preview-emoticon + .emoji-mart-preview-emoticon { 198 | margin-left: .5em; 199 | } 200 | 201 | .emoji-mart-preview-emoticon { 202 | font-size: 11px; 203 | color: #bbb; 204 | } 205 | 206 | .emoji-mart-title span { 207 | display: inline-block; 208 | vertical-align: middle; 209 | } 210 | 211 | .emoji-mart-title .emoji-mart-emoji { 212 | padding: 0; 213 | } 214 | 215 | .emoji-mart-title-label { 216 | color: #999A9C; 217 | font-size: 26px; 218 | font-weight: 300; 219 | } 220 | 221 | .emoji-mart-skin-swatches { 222 | font-size: 0; 223 | padding: 2px 0; 224 | border: 1px solid #d9d9d9; 225 | border-radius: 12px; 226 | background-color: #fff; 227 | } 228 | 229 | .emoji-mart-skin-swatches-opened .emoji-mart-skin-swatch { 230 | width: 16px; 231 | padding: 0 2px; 232 | } 233 | 234 | .emoji-mart-skin-swatches-opened .emoji-mart-skin-swatch-selected:after { 235 | opacity: .75; 236 | } 237 | 238 | .emoji-mart-skin-swatch { 239 | display: inline-block; 240 | width: 0; 241 | vertical-align: middle; 242 | transition-property: width, padding; 243 | transition-duration: .125s; 244 | transition-timing-function: ease-out; 245 | } 246 | 247 | .emoji-mart-skin-swatch:nth-child(1) { transition-delay: 0s } 248 | .emoji-mart-skin-swatch:nth-child(2) { transition-delay: .03s } 249 | .emoji-mart-skin-swatch:nth-child(3) { transition-delay: .06s } 250 | .emoji-mart-skin-swatch:nth-child(4) { transition-delay: .09s } 251 | .emoji-mart-skin-swatch:nth-child(5) { transition-delay: .12s } 252 | .emoji-mart-skin-swatch:nth-child(6) { transition-delay: .15s } 253 | 254 | .emoji-mart-skin-swatch-selected { 255 | position: relative; 256 | width: 16px; 257 | padding: 0 2px; 258 | } 259 | .emoji-mart-skin-swatch-selected:after { 260 | content: ""; 261 | position: absolute; 262 | top: 50%; left: 50%; 263 | width: 4px; height: 4px; 264 | margin: -2px 0 0 -2px; 265 | background-color: #fff; 266 | border-radius: 100%; 267 | pointer-events: none; 268 | opacity: 0; 269 | transition: opacity .2s ease-out; 270 | } 271 | 272 | .emoji-mart-skin { 273 | display: inline-block; 274 | width: 100%; padding-top: 100%; 275 | max-width: 12px; 276 | border-radius: 100%; 277 | } 278 | 279 | .emoji-mart-skin-tone-1 { background-color: #ffc93a } 280 | .emoji-mart-skin-tone-2 { background-color: #fadcbc } 281 | .emoji-mart-skin-tone-3 { background-color: #e0bb95 } 282 | .emoji-mart-skin-tone-4 { background-color: #bf8f68 } 283 | .emoji-mart-skin-tone-5 { background-color: #9b643d } 284 | .emoji-mart-skin-tone-6 { background-color: #594539 } 285 | -------------------------------------------------------------------------------- /src/components/chat/MessageForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { PropTypes } from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { Button } from 'react-bootstrap'; 6 | import { FaArrowAltCircleRight } from 'react-icons/fa'; 7 | import { FaSmile } from 'react-icons/fa'; 8 | import { Picker } from 'emoji-mart'; 9 | 10 | import emoji from '../../constants/emoji'; 11 | import { emojiSuggestions, mentionSuggestions } from '../../utils/suggestions'; 12 | import { 13 | messageUpdate, 14 | clearMessage, 15 | togglePicker, 16 | addEmoji, 17 | closePicker, 18 | startSuggestions, 19 | stopSuggestions, 20 | downSuggestion, 21 | upSuggestion, 22 | addSuggestion, 23 | sendMessage, 24 | } from '../../store/actions/chatActions'; 25 | 26 | import ToggleAudioIcon from './toolbar/ToggleAudioIcon'; 27 | import InviteIcon from './toolbar/InviteIcon'; 28 | import OpenSearchIcon from './toolbar/OpenSearchIcon'; 29 | 30 | class MessageForm extends Component { 31 | constructor(props) { 32 | super(props); 33 | 34 | this.messageInput = React.createRef(); 35 | } 36 | 37 | componentDidMount() { 38 | this.resolveFocus(); 39 | } 40 | 41 | componentDidUpdate() { 42 | this.resolveFocus(); 43 | } 44 | 45 | resolveFocus() { 46 | if (this.props.shouldHaveFocus) { 47 | this.messageInput.current.focus(); 48 | } 49 | } 50 | 51 | onKeyPress = (e) => { 52 | const cursorIndex = this.messageInput.current.selectionStart; 53 | const { suggestionStart, suggestions, highlightedSuggestion, statuses} = this.props; 54 | // Send on unless has been pressed 55 | if (e.key === 'Enter' && !e.nativeEvent.shiftKey) { 56 | if (suggestions.length > 0) { 57 | const selected = suggestions[highlightedSuggestion]; 58 | e.preventDefault(); 59 | return this.props.addSuggestion(selected.name); 60 | } 61 | this.onSendMessage(e); 62 | this.props.closePicker(); 63 | } 64 | if (e.key === ':' && suggestionStart === null) { 65 | this.props.startSuggestions(cursorIndex, emojiSuggestions); 66 | } 67 | if (e.key === '@' && suggestionStart === null) { 68 | this.props.startSuggestions(cursorIndex, mentionSuggestions, statuses); 69 | } 70 | if(e.nativeEvent.code === 'Space' && suggestionStart !== null) { 71 | this.props.stopSuggestions(); 72 | } 73 | }; 74 | 75 | onKeyDown = (e) => { 76 | const { message, suggestionWord, statuses } = this.props; 77 | const cursorIndex = this.messageInput.current.selectionStart; 78 | const before = message.slice(0, cursorIndex - 1); 79 | const word = suggestionWord; 80 | const filterSuggestions = word[0] === '@' 81 | ? mentionSuggestions 82 | : emojiSuggestions; 83 | if (e.key === 'Backspace' && before.endsWith(word) && word) { 84 | const start = before.length - word.length; 85 | this.props.startSuggestions(start, filterSuggestions, statuses); 86 | } 87 | }; 88 | 89 | isPayloadValid(message) { 90 | if (message && message.length > 0) { 91 | return true; 92 | } 93 | return false; 94 | } 95 | 96 | onSendMessage = (e) => { 97 | e.preventDefault(); 98 | 99 | const { message, username } = this.props; 100 | 101 | if (!this.isPayloadValid(message)) { 102 | return false; 103 | } 104 | 105 | this.props.sendMessage({ message, username }); 106 | this.props.clearMessage(); 107 | }; 108 | 109 | backgroundImageFn = (set, sheetSize) => { 110 | if (set !== 'apple' || sheetSize !== 64) { 111 | console.log('WARNING: using set "apple" and sheetSize 64 rather than', 112 | set, 'and', sheetSize, 'as was requested'); 113 | } 114 | return '/' + emoji.EMOJI_APPLE_64_SHEET; 115 | }; 116 | 117 | addEmoji = (emoji) => { 118 | const cursorIndex = this.messageInput.current.selectionStart; 119 | this.props.addEmoji(emoji.colons, cursorIndex); 120 | }; 121 | 122 | handleKeyDown = (e) => { 123 | const { suggestions, suggestionStart } = this.props; 124 | const cursorIndex = this.messageInput.current.selectionStart; 125 | if (e.key === 'Backspace' && cursorIndex - suggestionStart === 1) { 126 | this.props.stopSuggestions(); 127 | } 128 | if (e.key === 'ArrowUp' && suggestions.length > 0) { 129 | e.preventDefault(); 130 | this.props.upSuggestion(); 131 | } 132 | if (e.key === 'ArrowDown' && suggestions.length > 0) { 133 | e.preventDefault(); 134 | this.props.downSuggestion(); 135 | } 136 | }; 137 | 138 | onMessageUpdate = (e) => { 139 | const message = e.target.value; 140 | this.props.messageUpdate(message); 141 | }; 142 | 143 | render() { 144 | const { 145 | message, 146 | showEmojiPicker, 147 | username, 148 | messages, 149 | } = this.props; 150 | 151 | const { 152 | togglePicker, 153 | isAudioEnabled, 154 | onSetIsAudioEnabled } = this.props; 155 | 156 | return ( 157 |
    158 |
    159 | {showEmojiPicker && } 171 | 172 |
    173 |
    174 | 177 | 178 | 181 | 182 | 185 | 186 | 187 | 188 |
    189 |
    190 | 191 | 192 |
    193 | 203 | 206 |
    207 | 208 |
    209 | 210 | 211 |
    212 | ); 213 | } 214 | } 215 | 216 | MessageForm.propType = { 217 | shouldHaveFocus: PropTypes.bool.isRequired, 218 | onToggleModalVisibility: PropTypes.func.isRequired, 219 | }; 220 | 221 | const mapStateToProps = (reduxState) => { 222 | return { 223 | isAudioEnabled: reduxState.settings.isAudioEnabled, 224 | ...reduxState.chat, 225 | }; 226 | }; 227 | 228 | export default connect(mapStateToProps, { 229 | messageUpdate, 230 | clearMessage, 231 | togglePicker, 232 | addEmoji, 233 | closePicker, 234 | startSuggestions, 235 | stopSuggestions, 236 | downSuggestion, 237 | upSuggestion, 238 | addSuggestion, 239 | sendMessage, 240 | })(MessageForm); 241 | -------------------------------------------------------------------------------- /src/store/epics/chatEpics.js: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | CHAT_SET_USERNAME, 4 | CHAT_INIT_CONNECTION, 5 | CHAT_INIT_CHAT, 6 | CHAT_SEND_MESSAGE, 7 | connectionInitiated, 8 | disconnected, 9 | addMessage, 10 | messageSent, 11 | setUserStatus, 12 | userStatusSent, 13 | usernameSet, 14 | showSuggestions, 15 | stopSuggestions 16 | } from '../actions/chatActions'; 17 | 18 | import { 19 | alertSuccess, 20 | alertWarning 21 | } from '../actions/alertActions'; 22 | 23 | import { getPassphrase, getEmail } from '../../utils/encrypter'; 24 | import miniLock from '../../utils/miniLock'; 25 | 26 | import { USER_STATUS_DELAY_MS } from '../../constants/messaging'; 27 | import { persistUsername } from '../reducers/helpers/deviceState'; 28 | 29 | import ChatHandler from './helpers/ChatHandler'; 30 | import { combineEpics } from 'redux-observable'; 31 | import createDetectVisibilityObservable from './helpers/createDetectPageVisibilityObservable'; 32 | 33 | import { authUrl, wsUrl } from './helpers/urls'; 34 | 35 | export const chatHandler = new ChatHandler(wsUrl); 36 | 37 | import { Observable } from 'rxjs/Observable'; 38 | import { ajax } from 'rxjs/observable/dom/ajax'; 39 | import 'rxjs/add/operator/partition'; 40 | import 'rxjs/add/operator/mergeMap'; 41 | import 'rxjs/add/operator/delay'; 42 | import 'rxjs/add/operator/throttleTime'; 43 | import 'rxjs/add/operator/map'; 44 | import 'rxjs/add/operator/mapTo'; 45 | import 'rxjs/add/operator/catch'; 46 | import 'rxjs/add/operator/do'; 47 | import 'rxjs/add/operator/retry'; 48 | import 'rxjs/add/operator/pluck'; 49 | import 'rxjs/add/operator/filter'; 50 | import 'rxjs/add/operator/retryWhen'; 51 | import 'rxjs/add/observable/of'; 52 | import 'rxjs/add/observable/from'; 53 | import 'rxjs/add/observable/fromEvent'; 54 | import 'rxjs/add/observable/merge'; 55 | import 'rxjs/add/observable/if'; 56 | import 'rxjs/add/operator/takeUntil'; 57 | 58 | const messageEpic = (action$) => 59 | action$.ofType('CHAT_START_SUGGESTIONS') 60 | .mergeMap(({ cursorIndex, filterSuggestions, list }) => { 61 | return action$.ofType('CHAT_MESSAGE_UPDATE') 62 | .map((msg) => showSuggestions(cursorIndex, msg.message, filterSuggestions, list)) 63 | .takeUntil(action$.ofType('CHAT_STOP_SUGGESTIONS')) 64 | .catch((err) => console.error(err)); 65 | }); 66 | 67 | const suggestionEpic = (action$) => 68 | action$.ofType('CHAT_ADD_SUGGESTION') 69 | .map(() => stopSuggestions()) 70 | .catch((err) => console.error(err)); 71 | 72 | function createKeyPairObservable({ createDeviceSession, urlHash }) { 73 | return Observable.create(function (observer) { 74 | // 1. Get passphrase 75 | const { 76 | passphrase, 77 | isNewPassphrase 78 | } = getPassphrase(urlHash); 79 | 80 | // 2. Get email based on passphrase 81 | let email = getEmail(passphrase); 82 | // 3. Decrypt to get key pair 83 | miniLock.crypto.getKeyPair(passphrase, email, (keyPair) => { 84 | miniLock.session.keys = keyPair; 85 | miniLock.session.keyPairReady = true; 86 | let mID = miniLock.crypto.getMiniLockID(keyPair.publicKey); 87 | 88 | // 4. When we have keypair, login on device: 89 | if (isNewPassphrase) { 90 | createDeviceSession(passphrase); 91 | } 92 | 93 | observer.next({ passphrase, keyPair, mID, isNewRoom: isNewPassphrase }); 94 | observer.complete(); 95 | }); 96 | }); 97 | } 98 | 99 | function createDecryptMessageObservable({ message, mID, secretKey }) { 100 | return Observable.create(function (observer) { 101 | miniLock.crypto.decryptFile(message, mID, secretKey, 102 | function (fileBlob, saveName, senderID) { 103 | const reader = new FileReader(); 104 | reader.addEventListener("loadend", () => { 105 | const authToken = reader.result; 106 | observer.next(authToken); 107 | observer.complete(); 108 | }); 109 | 110 | reader.readAsText(fileBlob); 111 | }); 112 | }); 113 | } 114 | 115 | function getAuthRequestSettings({ mID }) { 116 | const settings = { 117 | url: authUrl, 118 | responseType: 'blob', 119 | headers: { 120 | 'X-Minilock-Id': mID 121 | }, 122 | method: 'GET' 123 | }; 124 | return settings; 125 | } 126 | 127 | const connectionAlertTtlSeconds = 4; 128 | 129 | const initConnectionEpic = (action$) => 130 | action$.ofType(CHAT_INIT_CONNECTION) 131 | .mergeMap(createKeyPairObservable) 132 | .mergeMap(({ keyPair, mID, isNewRoom, passphrase }) => 133 | ajax(getAuthRequestSettings({ mID })) 134 | .map(ajaxResult => ({ 135 | message: ajaxResult.response, 136 | mID, 137 | secretKey: keyPair.secretKey 138 | })) 139 | .mergeMap(createDecryptMessageObservable) 140 | .mergeMap(authToken => 141 | chatHandler.initConnection({ 142 | authToken, 143 | secretKey: keyPair.secretKey, 144 | mID 145 | }) 146 | ) 147 | .mergeMap(() => 148 | Observable.merge( 149 | chatHandler.getMessageSubject() 150 | .map(addMessage), 151 | 152 | chatHandler.getUserStatusSubject() 153 | .map(setUserStatus), 154 | 155 | Observable.of( 156 | connectionInitiated(), 157 | alertSuccess( 158 | `${isNewRoom ? 'New room created.' : ''} Connected to server.`, 159 | connectionAlertTtlSeconds 160 | ) 161 | ) 162 | ) 163 | ).catch(error => { 164 | console.error(error); 165 | return Observable.from([ 166 | alertWarning('Lost connection to chat server. Trying to reconnect...'), 167 | disconnected() 168 | ]); 169 | }) 170 | ) 171 | .catch(error => { 172 | console.error(error); 173 | return Observable.from([ 174 | alertWarning('Something went wrong when initiating. Trying again...'), 175 | disconnected() 176 | ]); 177 | }); 178 | 179 | const setUsernameEpic = (action$, store) => 180 | action$.ofType(CHAT_SET_USERNAME) 181 | .filter(action => action.username && !store.getState().chat.paranoidMode) 182 | .mergeMap(action => 183 | Observable.of(action.username) 184 | .do(username => { 185 | const ttl = USER_STATUS_DELAY_MS / 1000; 186 | chatHandler.sendUserStatus(username, 'viewing', ttl); 187 | }) 188 | .retryWhen(error => error.delay(500)) 189 | .do(() => persistUsername(action.username)) 190 | ) 191 | .map(username => usernameSet(username)); 192 | 193 | const ownUserStatusEpic = (action$, store) => 194 | action$.ofType(CHAT_INIT_CHAT) 195 | .mergeMap(() => 196 | createDetectVisibilityObservable() 197 | .throttleTime(USER_STATUS_DELAY_MS) 198 | .filter(() => store.getState().chat.username) 199 | .do(status => { 200 | const ttl = USER_STATUS_DELAY_MS / 1000; 201 | const { chat: { username } } = store.getState(); 202 | chatHandler.sendUserStatus(username, status, ttl); 203 | }) 204 | .map(() => userStatusSent()) 205 | ) 206 | .catch(error => { 207 | console.error(error); 208 | return Observable.of(alertWarning('Error sending user status.')); 209 | }); 210 | 211 | const sendMessageEpic = action$ => 212 | action$.ofType(CHAT_SEND_MESSAGE) 213 | .mergeMap(action => 214 | 215 | Observable.of(action) 216 | .do(action => { 217 | chatHandler.sendMessage(action.message, action.username); 218 | }) 219 | .retryWhen(error => error.delay(1000)) 220 | ) 221 | .map(() => messageSent()) 222 | .catch(error => { 223 | console.error(error); 224 | return Observable.of(alertWarning('Error sending message.')); 225 | }); 226 | 227 | export default combineEpics( 228 | setUsernameEpic, 229 | ownUserStatusEpic, 230 | initConnectionEpic, 231 | sendMessageEpic, 232 | messageEpic, 233 | suggestionEpic 234 | ); 235 | -------------------------------------------------------------------------------- /src/store/epics/helpers/ChatHandler.js: -------------------------------------------------------------------------------- 1 | import atob from 'atob'; 2 | import btoa from 'btoa'; 3 | import guid from 'guid'; 4 | import miniLock from '../../../utils/miniLock'; 5 | import { nowUTC } from '../../../utils/time'; 6 | import { extractMessageMetadata } from '../../../utils/chat'; 7 | import { Subject } from 'rxjs/Subject'; 8 | import { Observable } from 'rxjs/Observable'; 9 | 10 | import 'rxjs/add/operator/mergeMap'; 11 | import 'rxjs/add/operator/map'; 12 | import 'rxjs/add/operator/mapTo'; 13 | import 'rxjs/add/operator/catch'; 14 | import 'rxjs/add/operator/do'; 15 | import 'rxjs/add/observable/of'; 16 | import 'rxjs/add/observable/from'; 17 | import 'rxjs/add/observable/throw'; 18 | 19 | class ChatHandler { 20 | 21 | constructor(wsUrl) { 22 | this.wsUrl = wsUrl; 23 | this.wsMessageSubject = null; 24 | this.wsUserStatusSubject = null; 25 | } 26 | 27 | onWsClose = (event) => { 28 | console.error('Websocket got close event', event); 29 | this.wsMessageSubject.error(new Error('The websocket connection was closed!')); 30 | }; 31 | 32 | onWsError = (event) => { 33 | console.error('Websocket got error event', event); 34 | this.wsMessageSubject.error(new Error('Error occurred on websocket connection!')); 35 | }; 36 | 37 | onWsMessage = (event) => { 38 | const data = JSON.parse(event.data); 39 | if (data && data.ephemeral && data.ephemeral.length && data.ephemeral.length > 0) { 40 | Observable.from(data.ephemeral) 41 | .mergeMap(ephemeral => 42 | this.createDecryptEphemeralObservable({ ephemeral, mID: this.mID, secretKey: this.secretKey })) 43 | .mergeMap(this.resolveIncomingMessageTypeObservable) 44 | .catch(error => console.error('An error occurred in ChatHandler', error)) 45 | .subscribe(); 46 | } 47 | if (data && data.from_server) { 48 | if (data.from_server.all_messages_deleted) { 49 | alert("All messages deleted from server! (Refresh this page to remove them from this browser tab.)"); 50 | } 51 | } 52 | }; 53 | 54 | resolveIncomingMessageTypeObservable = (message) => { 55 | if (message.meta.isChatMessage) { 56 | 57 | const reader = new FileReader(); 58 | const chatMessageObservable = 59 | Observable.fromEvent(reader, 'loadend') 60 | .pluck('target', 'result') 61 | .map(JSON.parse) 62 | .do(contents => { 63 | this.wsMessageSubject.next({ 64 | id: guid.create(), 65 | fromUsername: message.meta.from, 66 | maybeSenderId: message.maybeSenderId, 67 | message: contents.msg 68 | }); 69 | }); 70 | 71 | reader.readAsText(message.fileBlob); 72 | 73 | return chatMessageObservable; 74 | 75 | } else if (message.meta.isUserStatus) { 76 | 77 | return Observable.of({ 78 | username: message.meta.from, 79 | status: message.meta.status, 80 | created: nowUTC() 81 | }).do(status => this.wsUserStatusSubject.next(status)); 82 | 83 | } else { 84 | return Observable.throw(new Error('Unrecognized data with type ' + message.tags.type)); 85 | } 86 | }; 87 | 88 | createDecryptEphemeralObservable = ({ ephemeral, mID, secretKey }) => { 89 | return Observable.create(function (observer) { 90 | const binStr = atob(ephemeral); 91 | const binStrLength = binStr.length; 92 | const array = new Uint8Array(binStrLength); 93 | 94 | for (let i = 0; i < binStrLength; i++) { 95 | array[i] = binStr.charCodeAt(i); 96 | } 97 | const msg = new Blob([array], { type: 'application/octet-stream' }); 98 | miniLock.crypto.decryptFile(msg, 99 | mID, 100 | secretKey, 101 | (fileBlob, saveName, senderID) => { 102 | 103 | const tags = saveName.split('|||'); 104 | const meta = extractMessageMetadata(tags); 105 | 106 | let maybeSenderId = ''; 107 | if (senderID !== this.mID) { 108 | maybeSenderId = ' (' + senderID + ')'; 109 | } 110 | 111 | observer.next({ fileBlob, saveName, meta, senderID, maybeSenderId }); 112 | }); 113 | 114 | }); 115 | }; 116 | 117 | send({ contents = {}, tags, ttl = 0 }) { 118 | if (!this.ws) { 119 | throw new Error('WebSocket connection not initialized!'); 120 | } 121 | if (this.ws.readyState !== WebSocket.OPEN) { 122 | throw new Error('WebSocket connection is initializing and is not open yet!'); 123 | } 124 | 125 | const saveName = tags.join('|||'); 126 | const fileBlob = new Blob([JSON.stringify(contents)], { type: 'application/json' }); 127 | fileBlob.name = saveName; 128 | 129 | const mID = this.mID; 130 | const secretKey = this.secretKey; 131 | miniLock.crypto.encryptFile(fileBlob, saveName, [mID], 132 | mID, 133 | secretKey, 134 | this.sendMessageToServer.bind(this, ttl)); 135 | } 136 | 137 | sendMessage(message, username) { 138 | const contents = { 139 | msg: message, 140 | from: username, 141 | type: 'chatmessage' 142 | }; 143 | const tags = ['from:' + username, 'type:chatmessage']; 144 | this.send({ contents, tags }); 145 | } 146 | 147 | sendUserStatus = (username, status, ttl) => { 148 | const tags = ['from:' + username, 'type:userstatus', 'status:' + status]; 149 | this.send({ tags, ttl }); 150 | }; 151 | 152 | sendDeleteAllMessagesSignalToServer = () => { 153 | const msgForServer = { 154 | to_server: { 155 | delete_all_messages: true 156 | } 157 | }; 158 | this.ws.send(JSON.stringify(msgForServer)); 159 | }; 160 | 161 | sendMessageToServer = (ttl_secs, fileBlob, saveName, senderMinilockID) => { 162 | const reader = new FileReader(); 163 | reader.addEventListener("loadend", () => { 164 | // From https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string#comment55137593_11562550 165 | const b64encMinilockFile = btoa([].reduce.call( 166 | new Uint8Array(reader.result), 167 | function (p, c) { 168 | return p + String.fromCharCode(c); 169 | }, '')); 170 | 171 | const msgForServer = { 172 | ephemeral: [b64encMinilockFile] 173 | }; 174 | if (ttl_secs > 0) { 175 | msgForServer.to_server = { 176 | ttl_secs: ttl_secs 177 | }; 178 | } 179 | this.ws.send(JSON.stringify(msgForServer)); 180 | }); 181 | reader.readAsArrayBuffer(fileBlob); 182 | }; 183 | 184 | initConnectionNew = ({ mID, secretKey, authToken, isNewRoom }) => { 185 | this.mID = mID; 186 | this.secretKey = secretKey; 187 | this.authToken = authToken; 188 | this.wsMessageSubject = new Subject(); 189 | this.wsUserStatusSubject = new Subject(); 190 | 191 | return Observable.create(observer => { 192 | this.ws = new WebSocket(this.wsUrl); 193 | this.ws.onclose = this.onWsClose; 194 | this.ws.onerror = this.onWsError; 195 | this.ws.onmessage = this.onWsMessage; 196 | this.ws.onopen = (event) => { 197 | event.target.send(this.authToken); 198 | observer.next(isNewRoom); 199 | observer.complete(); 200 | }; 201 | 202 | }); 203 | }; 204 | 205 | initConnection = ({ mID, secretKey, authToken }) => { 206 | this.mID = mID; 207 | this.secretKey = secretKey; 208 | this.authToken = authToken; 209 | this.wsMessageSubject = new Subject(); 210 | this.wsUserStatusSubject = new Subject(); 211 | 212 | return Observable.create(observer => { 213 | this.ws = new WebSocket(this.wsUrl); 214 | this.ws.onclose = this.onWsClose; 215 | this.ws.onerror = this.onWsError; 216 | this.ws.onmessage = this.onWsMessage; 217 | this.ws.onopen = (event) => { 218 | event.target.send(this.authToken); 219 | observer.next(); 220 | observer.complete(); 221 | }; 222 | 223 | }); 224 | }; 225 | 226 | cleanUp = () => { 227 | this.wsMessageSubject.complete(); 228 | this.wsMessageSubject = null; 229 | this.wsUserStatusSubject.complete(); 230 | this.wsUserStatusSubject = null; 231 | this.ws.close(); 232 | this.ws = null; 233 | this.mID = null; 234 | this.secretKey = null; 235 | this.authToken = null; 236 | }; 237 | 238 | getMessageSubject = () => { 239 | return this.wsMessageSubject; 240 | }; 241 | 242 | getUserStatusSubject = () => { 243 | return this.wsUserStatusSubject; 244 | }; 245 | 246 | } 247 | 248 | export default ChatHandler; 249 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= 2 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 4 | github.com/cathalgarvey/base58 v0.0.0-20150930172411-5e83fd6f66e3 h1:UiCAzJ/7RzEVGU8SxQcrgtkbOUCDYfGYHAZHYvWbW3o= 5 | github.com/cathalgarvey/base58 v0.0.0-20150930172411-5e83fd6f66e3/go.mod h1:JHU0bsaIIAwKdJ84j3JNHDGsXehsQIS6EySrtGIZ4fs= 6 | github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= 7 | github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= 8 | github.com/cryptag/go-minilock v0.0.0-20160315171457-c7289f173516 h1:2IfztZwF6swS/zXafkcBppYad9lO+Cvd2Au/IICmXTM= 9 | github.com/cryptag/go-minilock v0.0.0-20160315171457-c7289f173516/go.mod h1:caKtUaGD8uPTpeGNYJCZmHe9c1viq8LxZtlsVPgMGHc= 10 | github.com/cryptag/go-minilock v0.0.0-20230307201426-f138c5839651 h1:rOxjZMKV4wDUe1DHinSJ8oLgI7F3W8f9uI4T17KnD48= 11 | github.com/cryptag/go-minilock v0.0.0-20230307201426-f138c5839651/go.mod h1:mP3jjk8yMP5bPGrEG/jTgg3e1A3671eIPo35BVjfO+M= 12 | github.com/cryptag/gosecure v0.0.0-20180117073251-9b5880940d72 h1:Sq2y8zqJ0Jn06iwMQkp1jVAdReL2T3QCqfrVu4IhoSc= 13 | github.com/cryptag/gosecure v0.0.0-20180117073251-9b5880940d72/go.mod h1:x0RZxj8S2BI5vAoBOTvjZk1pawEY5+9n0xp8wzd3VQg= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/dchest/blake2s v1.0.0 h1:gHCBR8ecSImY/Nwk7X0Q2KJAJcpI/HSkUAQDi8MCP4Q= 18 | github.com/dchest/blake2s v1.0.0/go.mod h1:GrKn2Lc4hWqAwRrbneYuvZ6kugiJMrjk3HHtcJkEhbs= 19 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 20 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 21 | github.com/gorilla/mux v1.7.1 h1:Dw4jY2nghMMRsh1ol8dv1axHkDwMQK2DHerMNJsIpJU= 22 | github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 23 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 24 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 25 | github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= 26 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A= 27 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8= 28 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 29 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= 30 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 31 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 32 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= 36 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 40 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 41 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 42 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 43 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 44 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 45 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 47 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 48 | github.com/tv42/base58 v1.0.0 h1:ZN6pfg9LN98oUzMfc9axMNXuWxqJezO2S+atn1S5f4U= 49 | github.com/tv42/base58 v1.0.0/go.mod h1:JvBtPdU9grJ9mB4/W/j8gK5KJwXHkwIrB9DC2snzGC4= 50 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 51 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 52 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 53 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 54 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 55 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 56 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 57 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 58 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 59 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 60 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 61 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 62 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 63 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 64 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 65 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 67 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 68 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 69 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 74 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 76 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 77 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 78 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 79 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 80 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 81 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 82 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 83 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 84 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 85 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 86 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 87 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 88 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 89 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 91 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 94 | launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= 95 | launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= 96 | -------------------------------------------------------------------------------- /src/static/sass/_layout.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | /* iOS viewport bug fix (bottom of screen covered by control bar) */ 3 | /* CSS var `--vh` set in JS; see `src/utils/vh_fix.js` */ 4 | min-height: 100vh; 5 | min-height: fill-available; 6 | min-height: calc(var(--vh, 1vh) * 100); 7 | height: 100vh; 8 | height: fill-available; 9 | height: calc(var(--vh, 1vh) * 100); 10 | } 11 | 12 | html, body, ul { 13 | padding: 0; 14 | margin: 0; } 15 | 16 | body { 17 | color: #222; 18 | font-size: 16px; 19 | font-family: 'Lato', sans-serif; 20 | } 21 | 22 | // Desktop - Max height & width 23 | @media (min-width: 768px) { 24 | #page { 25 | display: flex; 26 | flex-direction: row; 27 | max-width: 100vw; 28 | max-height: calc(var(--vh, 1vh) * 100); 29 | } 30 | } 31 | 32 | // Mobile - Max & min height 33 | @media (max-width: 767px) { 34 | #page { 35 | display: flex; 36 | flex-direction: column; 37 | max-height: calc(var(--vh, 1vh) * 100); 38 | min-height: calc(var(--vh, 1vh) * 100); 39 | } 40 | } 41 | 42 | $funky-purple: #663399; 43 | 44 | 45 | .encloser { 46 | display: flex; 47 | flex-direction: row; 48 | } 49 | 50 | @media (max-width: 768px) { 51 | .encloser { 52 | flex-direction: column; 53 | } 54 | } 55 | 56 | // HEADER - mobile settings 57 | header { 58 | display: flex; 59 | background-color: $base-color; 60 | color: #fff; 61 | padding: 10px 10px 5px; 62 | flex-direction: row; 63 | justify-content: space-between; 64 | flex-wrap: wrap; 65 | min-height: fit-content; 66 | 67 | .users-icon { 68 | order: -1; 69 | flex-grow: 1; 70 | svg { 71 | cursor: pointer; 72 | } 73 | } 74 | 75 | .users-list { 76 | flex: 0 1 100%; 77 | 78 | ul { 79 | 80 | list-style-type: none; 81 | li { 82 | svg { 83 | float: left; 84 | } 85 | } 86 | } 87 | 88 | .invite-users { 89 | margin-top: 15px; 90 | 91 | button { 92 | color: white; 93 | border: solid 1px white; 94 | border-radius: 5px; 95 | } 96 | } 97 | } 98 | 99 | .logo-container { 100 | display: flex; 101 | justify-content: space-between; 102 | flex-grow: 1; 103 | } 104 | 105 | .logo { 106 | text-align: center; 107 | font-size: 24px; 108 | font-weight: bold; 109 | } 110 | 111 | .settings {} 112 | } 113 | 114 | // HEADER - desktop settings 115 | @media (min-width: 768px) { 116 | header { 117 | flex: 1; 118 | display: inline-flex; 119 | max-width: 306px; 120 | flex-direction: column; 121 | justify-content: start; 122 | height: calc(var(--vh, 1vh) * 100); 123 | 124 | .logo-container { 125 | margin-bottom: 15px; 126 | order: -1; 127 | flex-grow: 0; 128 | } 129 | 130 | .settings { 131 | float: right; 132 | margin-top: 3px; 133 | margin-left: auto; 134 | cursor: pointer; 135 | } 136 | 137 | .info { 138 | float: left; 139 | } 140 | 141 | .users-icon { 142 | display: none; 143 | } 144 | 145 | .users-list { 146 | flex-grow: 0; 147 | flex: none; 148 | display: block !important; 149 | 150 | ul { 151 | 152 | li { 153 | margin-bottom: 1px; 154 | word-wrap: break-word; 155 | } 156 | } 157 | } 158 | } 159 | .modal-header { 160 | border-bottom: none; 161 | } 162 | .message { 163 | display: flex; 164 | flex-direction: row; 165 | } 166 | .form-control { 167 | flex-grow: 2; 168 | } 169 | .emoji-picker-icon { 170 | cursor: pointer; 171 | } 172 | 173 | .form-label { 174 | font-weight: bold; 175 | } 176 | } 177 | 178 | .modal-content a { 179 | color: $funky-purple; 180 | } 181 | 182 | // Sharing overlay - desktop settings 183 | @media (min-width: 768px) { 184 | .share-copy-link { 185 | .current-href { 186 | width: 60%; 187 | } 188 | } 189 | } 190 | 191 | // Sharing overlay - mobile settings 192 | @media (max-width: 999px) { 193 | .share-copy-link { 194 | .current-href { 195 | width: 100%; 196 | border-radius: 7px !important; 197 | margin-bottom: 5px; 198 | } 199 | .icon-button { 200 | display: flex; 201 | flex-direction: row; 202 | justify-content: center; 203 | align-items: center; 204 | border-radius: 7px !important; 205 | width: 100%; 206 | } 207 | } 208 | } 209 | 210 | // MAIN - mobile settings 211 | main { 212 | 213 | display: flex; 214 | flex-grow: 1; 215 | max-width: 100vw; 216 | 217 | .content { 218 | display: flex; 219 | flex-direction: column; 220 | flex-grow: 1; 221 | position: relative; 222 | 223 | .message-list { 224 | padding: 0 15px; 225 | } 226 | 227 | .message-form { 228 | padding: 5px; 229 | background-color: white; 230 | border-top: solid 1px #ccc; 231 | margin-top: auto; 232 | 233 | .message { 234 | display: flex; 235 | 236 | textarea { 237 | /* this font-size disables zooming 238 | on mobile safari, which breaks layout */ 239 | font-size: 16px; 240 | } 241 | } 242 | 243 | button { 244 | border: none; 245 | padding: 5px; 246 | float: right; 247 | height: 54px; 248 | vertical-align: bottom; 249 | margin-left: 5px; 250 | 251 | i { 252 | color: #1e202f; 253 | } 254 | } 255 | } 256 | } // end .content 257 | } // end main 258 | 259 | .message-box, 260 | .search-results { 261 | // scroll with flexbox needs revisiting 262 | // for now, message box is viewport height 263 | // less display height of message form 264 | // + display height of header 265 | // 266 | // 157px is a magic number -- and one that works well on 2 desktop 267 | // browsers, the Android native app (Capacitor), and the Android 268 | // emulator. We need to explicitly set the heights set more heights 269 | // of things to avoid the magic number, methinks. --@elimisteve 270 | height: calc(var(--vh, 1vh) * 100 - 157px - var(--androidTopBar, 0)); 271 | overflow-y: scroll; 272 | padding-top: 5px; 273 | 274 | ul { 275 | margin-top: 10px; 276 | 277 | li.chat-message { 278 | display: inline-block; 279 | border: solid 1px #aaa; 280 | border-radius: 10px; 281 | padding: 5px 10px; 282 | margin-bottom: 6px; 283 | width: 90%; 284 | word-wrap: break-word; 285 | box-shadow: 2px 1px 2px #aaa, -1px 0 1px #aaa; 286 | 287 | .username { 288 | display: block; 289 | font-weight: bold; 290 | font-size: 0.85em; 291 | } 292 | 293 | p { 294 | margin: 0 0 3px; 295 | } 296 | 297 | &.chat-outgoing { 298 | float: right; 299 | color: white; 300 | background-color: $funky-purple; 301 | 302 | p { 303 | margin: 2px 0; 304 | } 305 | 306 | .username { 307 | display: none; 308 | } 309 | 310 | a { 311 | text-decoration: underline; 312 | color: white; 313 | } 314 | 315 | a:hover { 316 | color: #ddd; 317 | } 318 | } 319 | 320 | &.chat-incoming { 321 | float: left; 322 | color: #333; 323 | } 324 | 325 | ul { 326 | list-style-type: disc; 327 | margin-left: 20px; 328 | } 329 | 330 | img[src^='/static/img/emoji'] { 331 | width: 24px; 332 | height: 24px; 333 | display: inline-block; 334 | background-size: 100%; 335 | vertical-align: bottom; 336 | } 337 | } 338 | } 339 | } 340 | 341 | .search-results { 342 | height: auto; 343 | max-height: calc(var(--vh, 1vh) * 50); 344 | padding-right: 10px; 345 | } 346 | 347 | 348 | // MAIN - desktop settings 349 | @media (min-width: 768px) { 350 | main { 351 | display: inline-flex; 352 | flex: 1; 353 | flex-direction: row; 354 | justify-content: start; 355 | height: calc(var(--vh, 1vh) * 100); 356 | width: 50vw !important; 357 | 358 | .content { 359 | max-width: 100%; 360 | .message-box{ 361 | // scroll with flexbox needs revisiting 362 | // for now, message box is viewport height 363 | // less display height of message form 364 | height: calc(var(--vh, 1vh) * 100 - 70px); 365 | box-shadow: inset 2px 2px 2px #aaa; 366 | } 367 | } 368 | } 369 | } 370 | 371 | // Alert styles, overrides default bootstrap 372 | // styles for positioning 373 | .alert-container { 374 | position: absolute; 375 | top: 5px; 376 | left: 10px; 377 | right: 10px; 378 | font-size: 14px; 379 | z-index: 10; 380 | 381 | .alert { 382 | 383 | 384 | &.alert-dismissable { 385 | .close { 386 | right: auto; 387 | } 388 | } 389 | } 390 | } 391 | 392 | // Info Positioning relative to Logo 393 | #logo-info { 394 | display: inherit; 395 | .info { 396 | padding-left: 10px; 397 | svg { 398 | vertical-align: top; 399 | cursor: pointer; 400 | } 401 | } 402 | } 403 | 404 | .search-results { 405 | ul { 406 | li.chat-message { 407 | } 408 | } 409 | } 410 | 411 | .icon-button { 412 | display: flex; 413 | flex-direction: row; 414 | align-items: center; 415 | text-decoration: none; 416 | 417 | &:hover { 418 | text-decoration: none; 419 | } 420 | 421 | svg { 422 | margin: 0 6px; 423 | } 424 | } 425 | 426 | 427 | // Cursor effect over Settings button 428 | .settings { 429 | svg { 430 | cursor: pointer; 431 | } 432 | } 433 | 434 | // Override Bootstrap default 435 | .btn-primary, 436 | .btn-primary:hover, 437 | .btn-primary:active, 438 | .btn-primary:visited { 439 | background-color: $funky-purple !important; 440 | border-color: $funky-purple !important; 441 | } 442 | 443 | // Allows textarea to be dynamic instead of static 444 | textarea { 445 | box-sizing: border-box !important; 446 | max-height: 40vh !important; 447 | width: 100% !important; 448 | } 449 | 450 | // All widths 451 | .chat-icons { 452 | display: flex; 453 | flex-direction: row; 454 | margin-bottom: 5px; 455 | width: 100%; 456 | padding-left: 5px; 457 | gap: 10px; 458 | 459 | .sharing, 460 | .toggle-audio, 461 | .open-message-search { 462 | cursor: pointer; 463 | } 464 | } 465 | .chat-icons .right-chat-icons { 466 | display: flex; 467 | flex-direction: row; 468 | height: 100%; 469 | margin-left: auto; 470 | } 471 | .right-chat-icons .delete-all-msgs { 472 | display: flex; 473 | flex-direction: row; 474 | background-color: #c00; 475 | color: white; 476 | } 477 | .delete-all-msgs:hover { 478 | background-color: #e00; 479 | } 480 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LeapChat 2 | 3 | LeapChat is an ephemeral chat application. LeapChat uses 4 | [miniLock](https://web.archive.org/web/20180508023310/https://minilock.io/) for challenge/response-based 5 | authentication. This app also enables users to create chat rooms, 6 | invite others to said rooms (via a special URL with a passphrase at 7 | the end of it that is used to generate a miniLock keypair), and of 8 | course send (encrypted) messages to the other chat room participants. 9 | 10 | 11 | ## Security Features 12 | 13 | - All messages are encrypted end-to-end 14 | 15 | - The server cannot see anyone's usernames, which are encrypted and 16 | attached to each message 17 | 18 | - Users can "leap" from one room to the next so that if an adversary 19 | clicks on an old invite link, it cannot be used to join the room 20 | - (Feature coming soon!) 21 | 22 | - [Very secure headers](https://securityheaders.io/?q=https%3A%2F%2Fwww.leapchat.org&followRedirects=on) 23 | thanks to [gosecure](https://github.com/cryptag/gosecure). 24 | 25 | - TODO (many more) 26 | 27 | 28 | ## Instances 29 | 30 | There is currently one public instance running at 31 | [leapchat.org](https://www.leapchat.org). 32 | 33 | 34 | # Running LeapChat 35 | 36 | ## Getting Started 37 | 38 | ### Install Go 39 | 40 | If you're on Linux or macOS _and_ if don't already have 41 | [Go](https://golang.org/dl/) version 1.14 or newer installed 42 | (`$ go version` will tell you), you can install Go by running: 43 | 44 | ``` 45 | curl https://raw.githubusercontent.com/elimisteve/install-go/master/install-go.sh | bash 46 | source ~/.bashrc 47 | ``` 48 | 49 | Then grab and build the `leapchat` source: 50 | 51 | ``` 52 | go get github.com/cryptag/leapchat 53 | ``` 54 | 55 | ### JavaScript and Node Setup 56 | 57 | Install Node v14. We recommend using the [Node Version Manager (nvm)](https://github.com/nvm-sh/nvm) package 58 | to manage your node environments. 59 | 60 | If you're using NVM, you can install the correct node version by running: 61 | 62 | ``` 63 | nvm install # run from inside of leapchat/ dir, uses .nvmrc file 64 | nvm install v14.0.0 # run from anywhere 65 | ``` 66 | 67 | Then, to configure the use of the correct node version whenever you enter the project: 68 | 69 | ``` 70 | cd ~/code/leapchat && nvm use 71 | ``` 72 | 73 | To install JavaScript dependencies: 74 | 75 | ``` 76 | npm install 77 | ``` 78 | 79 | In development, when you want to see your frontend code changes immediately on a browser refresh, run 80 | the command that boots up a watch process to re-compile the frontend whenever a file changes: 81 | 82 | ``` 83 | npm run dev 84 | ``` 85 | 86 | In order to do a one-time build of the production assets: 87 | 88 | ``` 89 | npm run build 90 | ``` 91 | 92 | The frontend is served through an HTTP server running in the go binary. This allows us to make API requests 93 | from the browser without any CORS configuration. 94 | 95 | ### macOS Instructions 96 | 97 | If you don't already have Postgres 9.5 to Postgres 12 installed and 98 | running, install it with Homebrew: 99 | 100 | ``` 101 | brew install postgresql@12 102 | ``` 103 | 104 | (It may ask you to append a line to your shell config; watch for this 105 | and follow those instructions.) 106 | 107 | Next, you'll need three terminals. 108 | 109 | **In the first terminal**, run database migrations, download `postgrest`, 110 | and have `postgrest` connect to Postgres: 111 | 112 | Start the PostgreSQL server: 113 | 114 | ``` 115 | brew services start postgresql@12 116 | ``` 117 | 118 | Then, create the default database and run the migrations. 119 | 120 | ``` 121 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db 122 | chmod a+rx ~/ 123 | createdb 124 | sudo -u $USER bash init_sql.sh 125 | ``` 126 | 127 | We use [PostgREST](https://postgrest.org/en/stable/) to expose the database to the application. 128 | PostgREST provides a REST API interface that maps to the underlying tables. 129 | 130 | To install and run the REST API with the LeapChat configuration file: 131 | 132 | ``` 133 | brew install postgrest 134 | postgrest db/postgrest.conf 135 | ``` 136 | 137 | If you get an error, make sure that Postgres is running. On Mac OS, 138 | you can check PostgreSQL status by running: 139 | 140 | ``` 141 | brew services list 142 | ``` 143 | 144 | **In the second terminal**, run LeapChat's Go backend: 145 | 146 | ``` 147 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat 148 | go build 149 | ./leapchat 150 | ``` 151 | 152 | **In the third terminal**, install JavaScript dependencies and start 153 | LeapChat's auto-reloading dev server: 154 | 155 | ``` 156 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat 157 | npm install 158 | npm run dev 159 | ``` 160 | 161 | LeapChat's dev server should now be running on ! 162 | 163 | 164 | #### macOS: Once you're set up 165 | 166 | ...then run in 3 different terminals: 167 | 168 | ``` 169 | brew services start postgresql@12 170 | postgrest db/postgrest.conf 171 | ``` 172 | 173 | ``` 174 | ./leapchat 175 | ``` 176 | 177 | ``` 178 | npm run dev 179 | ``` 180 | 181 | ### Linux Instructions (for Ubuntu; works on Debian if other dependencies met) 182 | 183 | If you don't already have Node 14.x installed (`node --version` will tell 184 | you the installed version), install Node by running: 185 | 186 | ``` 187 | curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - 188 | sudo apt-get install nodejs 189 | ``` 190 | 191 | 192 | If you don't already have Postgres 9.5 or newer installed and running, 193 | install it by running: 194 | 195 | ``` 196 | sudo apt-get install postgresql postgresql-contrib 197 | ``` 198 | 199 | Next, you'll need three terminals. 200 | 201 | **In the first terminal**, run database migrations, download `postgrest`, 202 | and have `postgrest` connect to Postgres: 203 | 204 | ``` 205 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db 206 | chmod a+rx ~/ 207 | sudo -u postgres bash init_sql.sh 208 | wget https://github.com/PostgREST/postgrest/releases/download/v7.0.0/postgrest-v7.0.0-ubuntu.tar.xz 209 | tar xvf postgrest-v7.0.0-ubuntu.tar.xz 210 | ./postgrest postgrest.conf 211 | ``` 212 | 213 | **In the second terminal**, run LeapChat's Go backend: 214 | 215 | ``` 216 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat 217 | go build 218 | ./leapchat 219 | ``` 220 | 221 | **In the third terminal**, install JavaScript dependencies and start 222 | LeapChat's auto-reloading dev server: 223 | 224 | ``` 225 | cd $(go env GOPATH)/src/github.com/cryptag/leapchat 226 | npm install 227 | npm run build 228 | npm run start 229 | ``` 230 | 231 | LeapChat should now be running at ! 232 | 233 | 234 | #### Linux: Once you're set up 235 | 236 | ...then run in 3 different terminals: 237 | 238 | ``` 239 | cd db 240 | ./postgrest postgrest.conf 241 | ``` 242 | 243 | ``` 244 | ./leapchat 245 | ``` 246 | 247 | ``` 248 | npm run start 249 | ``` 250 | 251 | 252 | ### Production Build and Deploy 253 | 254 | Make sure you're in the default branch (currently `develop`), and make 255 | sure that `git diff` doesn't return anything, then run these commands 256 | to create a new, versioned release of LeapChat, perform a production 257 | build, then deploy that build to production: 258 | 259 | (Be sure to customize `version` to the actual new version number.) 260 | 261 | ``` 262 | make all-deploy version=1.2.3 263 | ``` 264 | 265 | Or to run the build, release, and deploy steps individually: 266 | 267 | ``` 268 | make -B build 269 | make release version=1.2.3 270 | make deploy version=1.2.3 271 | ``` 272 | 273 | If the build and release succeed but the upload (and thus the rest of 274 | the deployment) fails, you can deploy the latest local build (in 275 | `./releases/`) with 276 | 277 | ``` 278 | make upload 279 | ``` 280 | 281 | Once SSH'd in, kill the old `leapchat` process then run 282 | 283 | ``` 284 | cd ~/gocode/src/github.com/cryptag/leapchat 285 | tar xvf $(ls -t releases/*.tar.gz | head -1) 286 | sudo setcap cap_net_bind_service=+ep leapchat 287 | ./leapchat -prod -domain www.leapchat.org -http :80 -https :443 -iframe-origin www.leapchat.org 2>&1 | tee -a logs.txt 288 | ``` 289 | 290 | ## Documentation Links 291 | 292 | Open via `npm`: 293 | 294 | ``` 295 | npm docs bootstrap 296 | npm docs react-bootstrap 297 | npm docs react-icons 298 | ``` 299 | 300 | 301 | ## JavaScript Testing 302 | 303 | ### Unit Tests 304 | 305 | For unit tests, use [mocha](https://mochajs.org/) as the testing framework and test runner, with 306 | [chai](http://chaijs.com/)'s expect API. 307 | 308 | Unit tests are located at `./test/` and have an extension of `.test.js`. 309 | 310 | To run unit tests only, run: 311 | 312 | ``` 313 | npm run mocha 314 | ``` 315 | 316 | ### Browser Tests 317 | 318 | For browser tests, we use [playwright](https://playwright.dev/). This should be installed for you 319 | via `npm`, but you may need to install the playwright browser have tests run successfully: 320 | 321 | ``` 322 | npx playwright install-deps 323 | ``` 324 | 325 | Browser tests are located at `./test/playwright` and have an extension of `.spec.js`. 326 | 327 | To run browser tests only, run: 328 | 329 | ``` 330 | npm run playwright 331 | ``` 332 | 333 | By default, the browser tests run in headless mode. To run with an interactive browser session, run: 334 | 335 | ``` 336 | npm run webtests 337 | ``` 338 | 339 | **To run all of the tests, all together**: 340 | 341 | ``` 342 | npm test 343 | ``` 344 | 345 | ### Documentation Links 346 | 347 | Playwright has good docs. For quick access, here are some useful links: 348 | 349 | [Accessing the DOM with Playwright](https://playwright.dev/docs/api/class-locator) 350 | 351 | [Test Assertions with Playwright](https://playwright.dev/docs/test-assertions) 352 | 353 | Currently experimental: [Unit Testing React Components with Playwright](https://playwright.dev/docs/test-components) 354 | 355 | ## Go Testing 356 | 357 | To run the golang tests: 358 | 359 | ``` $ go test [-v] ./... ``` 360 | 361 | 362 | # Cryptography Notice 363 | 364 | This distribution includes cryptographic software. The country in which you currently reside may have restrictions on the import, possession, use, and/or re-export to another country, of encryption software. 365 | BEFORE using any encryption software, please check your country's laws, regulations and policies concerning the import, possession, or use, and re-export of encryption software, to see if this is permitted. 366 | See for more information. 367 | 368 | The U.S. Government Department of Commerce, Bureau of Industry and Security (BIS), has classified this software as Export Commodity Control Number (ECCN) 5D002.C.1, which includes information security software using or performing cryptographic functions with asymmetric algorithms. 369 | The form and manner of this distribution makes it eligible for export under the License Exception ENC Technology Software Unrestricted (TSU) exception (see the BIS Export Administration Regulations, Section 740.13) for both object code and source code. 370 | --------------------------------------------------------------------------------