├── .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 |
8 | {chat.suggestions.map((user, i) => {
9 | const activeItem = chat.highlightedSuggestion === i;
10 | const mention = user.name;
11 |
12 | let props = {
13 | key : i,
14 | onClick : (e) => addSuggestion(mention),
15 | className : activeItem ? 'active': '',
16 | };
17 | if (activeItem) {
18 | props.ref = (item) => {
19 | if (item) item.scrollIntoView(scrollIntoViewOptions);
20 | };
21 | }
22 | return
23 |
24 | {mention.slice(1)}
25 | ;
26 | })}
27 |
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 |
31 | {messages.map((message, i) => {
32 | return (
33 |
37 | );
38 | })}
39 |
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 |
10 | {chat.suggestions.map((emoj, i) => {
11 | const suggestion = emoji.replace_colons(emoj.name) + emoj.name;
12 | const suggestionMD = suggestion.replace(/<\/span>/g, '');
13 | const renderItem = md.renderInline(suggestionMD);
14 | const activeItem = chat.highlightedSuggestion === i;
15 | let props = {
16 | key : i,
17 | onClick : (e) => addSuggestion(emoj.name),
18 | className : activeItem ? 'active': '',
19 | };
20 | if (activeItem) {
21 | props.ref = (item) => {
22 | if (item) item.scrollIntoView(scrollIntoViewOptions);
23 | };
24 | }
25 | return ( );
26 | })}
27 |
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 |
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 |
40 | Delete All Messages Forever
41 |
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 ''
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 '';
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 | setShowSharingModal(true)}>
50 | Invite Users
51 |
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 |
43 | Share Link
44 |
45 |
46 |
47 | }
48 |
49 | Copy Link
50 | Invite with a link copied to your clipboard.
51 |
52 |
53 |
58 |
59 | Copy to Clipboard
60 |
61 |
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 | Generate Random Pincode
72 |
73 |
74 |
75 | {pincode && Cancel }
76 | Set Pincode
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 |
32 | Your browser loads the HTML, CSS, and JavaScript from the server (e.g., leapchat.org)
33 |
34 |
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 |
37 |
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 |
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 |
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 |
Username
103 |
114 |
115 | {failMessage &&
116 |
117 | Invalid Username:
118 | {failMessage}
119 |
}
120 |
Generate Random Username
121 |
122 | {!connected &&
123 | {statusMessage}
124 |
125 |
}
126 |
127 |
128 | {username && Cancel }
129 | Set Username
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 |
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 |
--------------------------------------------------------------------------------