add whatsapp-clone sample app (#1730)

This is a forked version of Urigo's [WhatsApp Clone](https://github.com/Urigo/WhatsApp-Clone-server), integrated with Hasura.
Hasura GraphQL APIs are used in place of custom resolvers. A simple JWT server replaces the original auth server. Image uploads use the same Cloudinary APIs.
[Original React Client](https://github.com/Urigo/WhatsApp-Clone-Client-React) has been updated to match Hasura APIs and typescript definitions have been generated using GraphQL Code Generator.
This commit is contained in:
Praveen Durairaj 2019-03-19 12:15:41 +05:30 committed by Shahidh K Muhammed
parent 2777b45335
commit 6223852e9d
90 changed files with 19662 additions and 0 deletions

View File

@ -0,0 +1,67 @@
# WhatsApp Clone
The react client is a forked version of [urigo/whatsapp-client-react](https://github.com/Urigo/WhatsApp-Clone-Client-React) and the server is backed by Hasura GraphQL Engine
- Checkout the [live app](https://whatsapp-clone.demo.hasura.app/).
- Explore the backend using [Hasura
Console](https://whatsapp-clone.demo.hasura.app/console).
## Running the app yourself
#### Deploy Postgres and GraphQL Engine on Heroku:
[![Deploy to
heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/hasura/graphql-engine-heroku)
- Get the Heroku app URL (say `whatsapp-clone.herokuapp.com`)
- Clone this repo:
```bash
git clone https://github.com/hasura/graphql-engine
cd graphql-engine/community/sample-apps/whatsapp-clone-typescript-react
```
- [Install Hasura CLI](https://docs.hasura.io/1.0/graphql/manual/hasura-cli/install-hasura-cli.html)
- Apply the migrations:
```bash
cd hasura
hasura migrate apply --endpoint "https://whatsapp-clone.herokuapp.com"
```
#### Run the auth server
```bash
cd auth-server
```
- Set the environment variables in `.env`
- Install and run the app
```bash
npm install
npm start
```
#### Run the react app
```bash
cd react-app
```
- Set the environment variables in `.env`
```bash
yarn install
```
- Modify the codegen.yml to include the correct endpoint and headers
- Generate the graphql types by running
```bash
gql-gen
```
This would generate the required types in `src/graphql/types`
- Run the app
```bash
yarn start
```

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,2 @@
node_modules
.env

View File

@ -0,0 +1,17 @@
FROM node:alpine
RUN mkdir -p /opt/app
RUN chown node:node /opt/app
WORKDIR /opt/app
COPY --chown=node:node package.json .
USER node
RUN npm install
COPY --chown=node:node . .
CMD npm start

View File

@ -0,0 +1 @@
web: npm start

View File

@ -0,0 +1,101 @@
# JWT Authentication server Boilerplate
Sample JWT Authentication server for generating a JWT to use in the `Authentication` header by the built in JWT decoder in Hasura GraphQL Engine when started in JWT mode.
## Getting Started
### Deploy locally
#### Local Prerequisites
- PostgreSQL up and accepting connections
- Hasura GraphQL engine up and accepting connections
- Node.js 8.9+ installed
#### Local instructions
Install NPM dependencies
```bash
npm install
```
Set environment variables. Open `.env` file and add the following env
```bash
ENCRYPTION_KEY=<replace_it_with_your_JWT_SECRET>
DATABASE_URL=postgres://<username>:<password>@<host>:<port>/<database_name>
CLOUDINARY_URL=<replace_it_with_your_cloudinary_url>
PORT=8010
```
##### User Schema
The following `users` table is assumed to be present in your schema. The table can have additional fields too.
| name | type | nullable | unique | default | primary |
| ---------- | ------- | -------- | ------ | ------- | ------- |
| id | Integer | no | yes | | yes |
| username | Text | no | yes | | no |
| password | Text | no | no | | no |
| token | Text | no | no | | no |
| created_at | Date | no | no | now() | |
Then start your app
```bash
npm start
```
## Usage
### Signup/Login
Once deployed or started locally, we can create an user using `/signup` API like below:
```bash
curl -H "Content-Type: application/json" \
-d'{"username": "test123", "password": "test123", "confirmPassword": "test123"}' \
http://localhost:8080/signup
```
On success, we get the response:
```json
{
"id": 1,
"username": "test123"
}
```
We can also use `/login` API to fetch the user token:
```bash
curl -H "Content-Type: application/json" \
-d'{"username": "test123", "password": "test123"}' \
http://localhost:8080/login
```
On success, we get the response:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsIm5hbWUiOiJ0ZXN0MTIzIiwiaWF0IjoxNTQwMjkyMzgyLjQwOSwiaHR0cHM6Ly9oYXN1cmEuaW8vand0L2NsYWltcyI6eyJ4LWhhc3VyYS1hbGxvd2VkLXJvbGVzIjpbImVkaXRvciIsInVzZXIiLCJtb2QiXSwieC1oYXN1cmEtdXNlci1pZCI6MSwieC1oYXN1cmEtZGVmYXVsdC1yb2xlIjoidXNlciJ9fQ.KtAUroqyBroBJL7O9og3Z4JnRkWNfr07cHQfeLarclU"
}
```
### Authenticate JWT using GraphQL Engine
The GraphQL engine comes with built in JWT authentication. You will need to start the engine with the same secret/key as the JWT auth server using the environment variable `HASURA_GRAPHQL_JWT_SECRET` (HASURA_GRAPHQL_ACCESS_KEY is also required see the docs)
In your GraphQL engine you will need to add permissions for a user named `user` with read permissions on the table and columns.
A sample CURL command using the above token would be:
```bash
curl -X POST \
http://localhost:8081/v1alpha1/graphql \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6InRlc3QxMjMiLCJpYXQiOjE1NDAzNzY4MTUuODUzLCJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsiZWRpdG9yIiwidXNlciIsIm1vZCJdLCJ4LWhhc3VyYS11c2VyLWlkIjoiMSIsIngtaGFzdXJhLWRlZmF1bHQtcm9sZSI6InVzZXIiLCJ4LWhhc3VyYS1yb2xlIjoidXNlciJ9fQ.w9uj0FtesZOFUnwYT2KOWHr6IKWsDRuOC9G2GakBgMI' \
-H 'Content-Type: application/json' \
-d '{ "query": "{ table { column } }" }'
```

View File

@ -0,0 +1,81 @@
/**
* Module dependencies.
*/
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const chalk = require('chalk');
const dotenv = require('dotenv');
const passport = require('passport');
const expressValidator = require('express-validator');
const cors = require('cors');
const cloudinary = require('cloudinary');
const multer = require('multer');
const tmp = require('tmp');
/**
* Load environment variables from .env file, where API keys and passwords are configured.
*/
dotenv.load({ path: '.env' });
if(!process.env.ENCRYPTION_KEY) {
throw new Error('JWT encryption key required')
}
/**
* Controllers (route handlers).
*/
/**
* Create Express server.
*/
const app = express();
/**
* Express configuration.
*/
app.use(cors());
app.set('host', '0.0.0.0');
app.set('port', process.env.PORT || 8080);
app.set('json spaces', 2); // number of spaces for indentation
app.use(bodyParser.json());
app.use(expressValidator());
app.use(passport.initialize());
app.use(passport.session());
const userController = require('./controllers/user');
const upload = multer({
dest: tmp.dirSync({ unsafeCleanup: true }).name,
})
const uploadProfilePic = (filePath) => {
return new Promise((resolve, reject) => {
cloudinary.v2.uploader.upload(filePath, (error, result) => {
if (error) {
reject(error)
} else {
resolve(result)
}
})
})
}
app.post('/login', userController.postLogin);
app.post('/signup', userController.postSignup);
app.post('/upload-profile-pic', upload.single('file'), async (req, res, done) => {
try {
res.json(await uploadProfilePic(req.file.path))
} catch (e) {
done(e)
}
})
/**
* Start Express server.
*/
app.listen(app.get('port'), () => {
console.log('%s App is running at http://localhost:%d in %s mode', chalk.green('✓'), app.get('port'), app.get('env'));
console.log(' Press CTRL-C to stop\n');
});
module.exports = app;

View File

@ -0,0 +1,28 @@
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const { User } = require('../db/schema');
passport.use(
new LocalStrategy({
usernameField: 'username',
passwordField: 'password'
},
function (username, password, done) {
User
.query()
.where('username', username)
.first()
.then(function (user) {
if (!user) { return done('Unknown user'); }
user.verifyPassword(password, function (err, passwordCorrect) {
if (err) { return done(err); }
if (!passwordCorrect) { return done('Invalid password'); }
return done(null, user)
})
}).catch(function (err) {
done(err)
})
}
));
module.exports = passport;

View File

@ -0,0 +1,83 @@
const passport = require('../config/passport');
const { User } = require('../db/schema');
const { errorHandler } = require('../db/errors');
const jwt = require('jsonwebtoken');
/**
* POST /login
* Sign in using username and password.
*/
exports.postLogin = async (req, res, next) => {
req.assert('username', 'Username is not valid').notEmpty();
req.assert('password', 'Password cannot be blank').notEmpty();
const errors = req.validationErrors();
if (errors) {
return res.status(400).json({'errors': errors});
}
passport.authenticate('local', (err, user) => {
if (err) { return handleResponse(res, 400, {'error': err})}
if (user) {
const tokenContents = {
sub: '' + user.id,
name: user.username,
iat: Date.now() / 1000,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["mine","user"],
"x-hasura-user-id": '' + user.id,
"x-hasura-default-role": "user",
"x-hasura-role": "user"
}
}
handleResponse(res, 200, {
token: jwt.sign(tokenContents, process.env.ENCRYPTION_KEY)
});
}
})(req, res, next);
};
/**
* POST /signup
* Create a new local account.
*/
exports.postSignup = async (req, res, next) => {
req.assert('name', 'Name is not valid').notEmpty();
req.assert('username', 'Username is not valid').notEmpty();
req.assert('password', 'Password must be at least 4 characters long').len(4);
req.assert('confirmPassword', 'Passwords do not match').equals(req.body.password);
const errors = req.validationErrors();
if (errors) {
return res.status(400).json({'errors': errors});
}
try {
await User.query()
.allowInsert('[name, username, password]')
.insert({
name: req.body.name,
username: req.body.username,
password: req.body.password
});
} catch (err) {
errorHandler(err, res);
return;
}
passport.authenticate('local', (err, user, info) => {
if (err) { return handleResponse(res, 400, {'error': err})}
if (user) {
handleResponse(res, 200, user.getUser());
}
})(req, res, next);
};
function handleResponse(res, code, statusMsg) {
res.status(code).json(statusMsg);
}

View File

@ -0,0 +1,120 @@
const {
ValidationError,
NotFoundError
} = require('objection');
const {
DBError,
ConstraintViolationError,
UniqueViolationError,
NotNullViolationError,
ForeignKeyViolationError,
CheckViolationError,
DataError
} = require('objection-db-errors');
// In this example `res` is an express response object.
function errorHandler(err, res) {
if (err instanceof ValidationError) {
switch (err.type) {
case 'ModelValidation':
res.status(400).send({
message: err.message,
type: 'ModelValidation',
data: err.data
});
break;
case 'RelationExpression':
res.status(400).send({
message: err.message,
type: 'InvalidRelationExpression',
data: {}
});
break;
case 'UnallowedRelation':
res.status(400).send({
message: err.message,
type: 'UnallowedRelation',
data: {}
});
break;
case 'InvalidGraph':
res.status(400).send({
message: err.message,
type: 'InvalidGraph',
data: {}
});
break;
default:
res.status(400).send({
message: err.message,
type: 'UnknownValidationError',
data: {}
});
break;
}
} else if (err instanceof NotFoundError) {
res.status(404).send({
message: err.message,
type: 'NotFound',
data: {}
});
} else if (err instanceof UniqueViolationError) {
res.status(409).send({
message: err.message,
type: 'UniqueViolation',
data: {
columns: err.columns,
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof NotNullViolationError) {
res.status(400).send({
message: err.message,
type: 'NotNullViolation',
data: {
column: err.column,
table: err.table,
}
});
} else if (err instanceof ForeignKeyViolationError) {
res.status(409).send({
message: err.message,
type: 'ForeignKeyViolation',
data: {
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof CheckViolationError) {
res.status(400).send({
message: err.message,
type: 'CheckViolation',
data: {
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof DataError) {
res.status(400).send({
message: err.message,
type: 'InvalidData',
data: {}
});
} else if (err instanceof DBError) {
res.status(500).send({
message: err.message,
type: 'UnknownDatabaseError',
data: {}
});
} else {
res.status(500).send({
message: err.message,
type: 'UnknownError',
data: {}
});
}
}
module.exports = { errorHandler }

View File

@ -0,0 +1,13 @@
exports.up = function(knex, Promise) {
return knex.schema.createTable('users', (table) => {
table.increments();
table.string('username').unique().notNullable();
table.string('password').notNullable();
table.timestamp('created_at').notNullable().defaultTo(knex.raw('now()'));
});
};
exports.down = function(knex, Promise) {
return knex.schema.dropTable('users');
};

View File

@ -0,0 +1,49 @@
const { promisify } = require('util');
const Knex = require('knex');
const connection = require('../knexfile');
const { Model } = require('objection');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const knexConnection = Knex(connection);
Model.knex(knexConnection);
class User extends Model {
static get tableName () {
return 'users'
}
static get idColumn() {
return 'id';
}
getUser() {
return {
'id': this.id,
'username': this.username,
}
}
async $beforeInsert () {
const salt = bcrypt.genSaltSync();
this.password = await bcrypt.hash(this.password, salt)
}
verifyPassword (password, callback) {
bcrypt.compare(password, this.password, callback)
};
static get jsonSchema () {
return {
type: 'object',
required: ['username'],
properties: {
id: {type: 'integer'},
username: {type: 'string', minLength: 1, maxLength: 255},
}
}
}
}
module.exports = { User }

View File

@ -0,0 +1,14 @@
// Update with your config settings.
const databaseName = "postgres";
const pg = require('pg');
const connection_url = process.env.DATABASE_URL || `postgres://postgres:@localhost:5432/${databaseName}`;
module.exports = {
client: 'pg',
connection: connection_url,
migrations: {
directory: __dirname + '/db/migrations'
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "passport-js-auth-webhook",
"version": "1.0.0",
"engines": {
"node": "8.9.1"
},
"description": "A boilerplate for Hasura Graphql Engine auth webhook with passport js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js",
"lint": "eslint \"**/*.js\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/hasura/graphql-engine.git"
},
"author": "Aravind Shankar",
"license": "MIT",
"bugs": {
"url": "https://github.com/hasura/graphql-engine/issues"
},
"homepage": "https://github.com/hasura/graphql-engine/tree/master/community/boilerplates/auth-webhooks/passport-js#readme",
"dependencies": {
"bcrypt": "3.0.0",
"body-parser": "^1.18.3",
"chalk": "^2.4.1",
"cloudinary": "^1.13.2",
"cors": "^2.8.5",
"dotenv": "^6.2.0",
"express": "^4.16.4",
"express-validator": "^5.3.0",
"jsonwebtoken": "^8.3.0",
"knex": "^0.15.2",
"multer": "^1.4.1",
"objection": "^1.3.0",
"objection-db-errors": "^1.0.0",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"pg": "^7.5.0",
"tmp": "0.0.33"
}
}

View File

@ -0,0 +1 @@
endpoint: http://localhost:8080

View File

@ -0,0 +1,427 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 10.5 (Debian 10.5-1.pgdg90+1)
-- Dumped by pg_dump version 10.1
-- Started on 2019-03-11 11:42:52 IST
-- TOC entry 285 (class 1255 OID 24760)
-- Name: truncate_tables(character varying); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION truncate_tables(username character varying) RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
statements CURSOR FOR
SELECT tablename FROM pg_tables
WHERE tableowner = username AND schemaname = 'public';
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(stmt.tablename) || ' CASCADE;';
END LOOP;
END;
$$;
SET default_tablespace = '';
SET default_with_oids = false;
--
-- TOC entry 216 (class 1259 OID 16585)
-- Name: chat; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE chat (
id integer NOT NULL,
name text,
picture text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
owner_id integer
);
--
-- TOC entry 3059 (class 0 OID 0)
-- Dependencies: 216
-- Name: TABLE chat; Type: COMMENT; Schema: public; Owner: -
--
COMMENT ON TABLE chat IS 'chats having an owner is a group';
--
-- TOC entry 3060 (class 0 OID 0)
-- Dependencies: 216
-- Name: COLUMN chat.owner_id; Type: COMMENT; Schema: public; Owner: -
--
COMMENT ON COLUMN chat.owner_id IS 'If owner_id is present, its a group chat';
--
-- TOC entry 217 (class 1259 OID 16592)
-- Name: chat_group_admins; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE chat_group_admins (
chat_id integer NOT NULL,
user_id integer NOT NULL
);
--
-- TOC entry 3061 (class 0 OID 0)
-- Dependencies: 217
-- Name: TABLE chat_group_admins; Type: COMMENT; Schema: public; Owner: -
--
COMMENT ON TABLE chat_group_admins IS 'chat group admin mapping';
--
-- TOC entry 219 (class 1259 OID 16597)
-- Name: chat_users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE chat_users (
chat_id integer NOT NULL,
user_id integer NOT NULL
);
--
-- TOC entry 3062 (class 0 OID 0)
-- Dependencies: 219
-- Name: TABLE chat_users; Type: COMMENT; Schema: public; Owner: -
--
COMMENT ON TABLE chat_users IS 'chat user mapping';
--
-- TOC entry 220 (class 1259 OID 16608)
-- Name: message; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE message (
id integer NOT NULL,
content text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
sender_id integer,
chat_id integer
);
--
-- TOC entry 222 (class 1259 OID 16617)
-- Name: recipient; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE recipient (
id integer NOT NULL,
received_at timestamp with time zone,
read_at timestamp with time zone,
user_id integer NOT NULL,
message_id integer NOT NULL
);
--
-- TOC entry 224 (class 1259 OID 16622)
-- Name: users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE users (
id integer NOT NULL,
username text NOT NULL,
password text NOT NULL,
name text DEFAULT ''''::text,
picture text,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- TOC entry 218 (class 1259 OID 16595)
-- Name: chat_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE chat_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- TOC entry 3063 (class 0 OID 0)
-- Dependencies: 218
-- Name: chat_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE chat_id_seq OWNED BY chat.id;
--
-- TOC entry 221 (class 1259 OID 16615)
-- Name: message_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE message_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- TOC entry 3064 (class 0 OID 0)
-- Dependencies: 221
-- Name: message_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE message_id_seq OWNED BY message.id;
--
-- TOC entry 226 (class 1259 OID 32940)
-- Name: message_user; Type: VIEW; Schema: public; Owner: -
--
CREATE VIEW message_user AS
SELECT message.id,
message.content,
message.created_at,
message.sender_id,
message.chat_id
FROM message
ORDER BY message.id DESC;
--
-- TOC entry 223 (class 1259 OID 16620)
-- Name: recipient_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE recipient_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- TOC entry 3065 (class 0 OID 0)
-- Dependencies: 223
-- Name: recipient_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE recipient_id_seq OWNED BY recipient.id;
--
-- TOC entry 225 (class 1259 OID 16630)
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE users_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- TOC entry 3066 (class 0 OID 0)
-- Dependencies: 225
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE users_id_seq OWNED BY users.id;
--
-- TOC entry 2892 (class 2604 OID 16632)
-- Name: chat id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat ALTER COLUMN id SET DEFAULT nextval('chat_id_seq'::regclass);
--
-- TOC entry 2894 (class 2604 OID 16634)
-- Name: message id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY message ALTER COLUMN id SET DEFAULT nextval('message_id_seq'::regclass);
--
-- TOC entry 2895 (class 2604 OID 16635)
-- Name: recipient id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY recipient ALTER COLUMN id SET DEFAULT nextval('recipient_id_seq'::regclass);
--
-- TOC entry 2898 (class 2604 OID 16636)
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass);
--
-- TOC entry 2902 (class 2606 OID 16638)
-- Name: chat_group_admins chat_group_admins_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_group_admins
ADD CONSTRAINT chat_group_admins_pkey PRIMARY KEY (chat_id, user_id);
--
-- TOC entry 2900 (class 2606 OID 16640)
-- Name: chat chat_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat
ADD CONSTRAINT chat_pkey PRIMARY KEY (id);
--
-- TOC entry 2904 (class 2606 OID 16642)
-- Name: chat_users chat_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_users
ADD CONSTRAINT chat_users_pkey PRIMARY KEY (chat_id, user_id);
--
-- TOC entry 2906 (class 2606 OID 16646)
-- Name: message message_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY message
ADD CONSTRAINT message_pkey PRIMARY KEY (id);
--
-- TOC entry 2908 (class 2606 OID 16648)
-- Name: recipient recipient_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY recipient
ADD CONSTRAINT recipient_pkey PRIMARY KEY (user_id, message_id);
--
-- TOC entry 2910 (class 2606 OID 16650)
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- TOC entry 2912 (class 2606 OID 16651)
-- Name: chat_group_admins chat_group_admins_chat_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_group_admins
ADD CONSTRAINT chat_group_admins_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chat(id);
--
-- TOC entry 2913 (class 2606 OID 16656)
-- Name: chat_group_admins chat_group_admins_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_group_admins
ADD CONSTRAINT chat_group_admins_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
--
-- TOC entry 2911 (class 2606 OID 16661)
-- Name: chat chat_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat
ADD CONSTRAINT chat_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id);
--
-- TOC entry 2914 (class 2606 OID 16666)
-- Name: chat_users chat_users_chat_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_users
ADD CONSTRAINT chat_users_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chat(id);
--
-- TOC entry 2915 (class 2606 OID 16671)
-- Name: chat_users chat_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY chat_users
ADD CONSTRAINT chat_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
--
-- TOC entry 2916 (class 2606 OID 16676)
-- Name: message message_chat_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY message
ADD CONSTRAINT message_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chat(id);
--
-- TOC entry 2917 (class 2606 OID 16681)
-- Name: message message_sender_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY message
ADD CONSTRAINT message_sender_id_fkey FOREIGN KEY (sender_id) REFERENCES users(id);
--
-- TOC entry 2918 (class 2606 OID 16686)
-- Name: recipient recipient_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY recipient
ADD CONSTRAINT recipient_message_id_fkey FOREIGN KEY (message_id) REFERENCES message(id);
--
-- TOC entry 2919 (class 2606 OID 16691)
-- Name: recipient recipient_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY recipient
ADD CONSTRAINT recipient_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
-- Completed on 2019-03-11 11:42:53 IST
--
-- PostgreSQL database dump complete
--

View File

@ -0,0 +1,416 @@
- type: replace_metadata
args:
functions: []
query_templates: []
remote_schemas: []
tables:
- array_relationships: []
delete_permissions: []
event_triggers: []
insert_permissions: []
object_relationships:
- comment: null
name: chat
using:
manual_configuration:
column_mapping:
chat_id: id
remote_table: chat
- comment: null
name: sender
using:
manual_configuration:
column_mapping:
sender_id: id
remote_table: users
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
limit: 1
role: user
table: message_user
update_permissions: []
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter: {}
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- chat_id
- user_id
set: {}
role: user
object_relationships: []
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- chat_id
- user_id
filter: {}
role: user
table: chat_group_admins
update_permissions:
- comment: null
permission:
columns:
- chat_id
- user_id
filter: {}
set: {}
role: user
- array_relationships:
- comment: null
name: users
using:
foreign_key_constraint_on:
column: chat_id
table: chat_users
- comment: null
name: messages
using:
foreign_key_constraint_on:
column: chat_id
table: message
delete_permissions:
- comment: null
permission:
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
- owner_id:
_is_null: true
columns:
- created_at
- id
- name
- owner_id
- picture
set: {}
role: user
object_relationships:
- comment: null
name: owner
using:
foreign_key_constraint_on: owner_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- created_at
- id
- name
- owner_id
- picture
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: chat
update_permissions:
- comment: null
permission:
columns:
- created_at
- id
- name
- owner_id
- picture
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
set: {}
role: user
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter:
_or:
- user_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- chat_id
- user_id
set: {}
role: user
object_relationships:
- comment: null
name: chat
using:
foreign_key_constraint_on: chat_id
- comment: null
name: user
using:
foreign_key_constraint_on: user_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- user_id
filter:
_or:
- user_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: chat_users
update_permissions: []
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter:
id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
id:
_eq: X-Hasura-User-Id
columns:
- created_at
- id
- name
- password
- picture
- username
set: {}
role: user
object_relationships: []
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- created_at
- id
- name
- password
- picture
- username
filter: {}
role: user
- comment: null
permission:
allow_aggregations: false
columns:
- created_at
- id
- name
- password
- picture
- username
filter:
id:
_eq: X-Hasura-User-Id
role: mine
table: users
update_permissions:
- comment: null
permission:
columns:
- created_at
- id
- name
- password
- picture
- username
filter:
id:
_eq: X-Hasura-User-Id
set: {}
role: user
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter: {}
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- id
- received_at
- read_at
- user_id
- message_id
set: {}
role: user
object_relationships:
- comment: null
name: user
using:
foreign_key_constraint_on: user_id
- comment: null
name: message
using:
foreign_key_constraint_on: message_id
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- id
- message_id
- read_at
- received_at
- user_id
filter: {}
role: user
table: recipient
update_permissions:
- comment: null
permission:
columns:
- id
- message_id
- read_at
- received_at
- user_id
filter: {}
set: {}
role: user
- array_relationships:
- comment: null
name: recipients
using:
foreign_key_constraint_on:
column: message_id
table: recipient
delete_permissions:
- comment: null
permission:
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
_or:
- sender_id:
_eq: X-Hasura-User-Id
columns:
- id
- content
- created_at
- sender_id
- chat_id
set: {}
role: user
object_relationships:
- comment: null
name: chat
using:
foreign_key_constraint_on: chat_id
- comment: null
name: sender
using:
foreign_key_constraint_on: sender_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: message
update_permissions:
- comment: null
permission:
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
set: {}
role: user

View File

@ -0,0 +1,414 @@
functions: []
query_templates: []
remote_schemas: []
tables:
- array_relationships: []
delete_permissions: []
event_triggers: []
insert_permissions: []
object_relationships:
- comment: null
name: chat
using:
manual_configuration:
column_mapping:
chat_id: id
remote_table: chat
- comment: null
name: sender
using:
manual_configuration:
column_mapping:
sender_id: id
remote_table: users
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
limit: 1
role: user
table: message_user
update_permissions: []
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter: {}
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- chat_id
- user_id
set: {}
role: user
object_relationships: []
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- chat_id
- user_id
filter: {}
role: user
table: chat_group_admins
update_permissions:
- comment: null
permission:
columns:
- chat_id
- user_id
filter: {}
set: {}
role: user
- array_relationships:
- comment: null
name: users
using:
foreign_key_constraint_on:
column: chat_id
table: chat_users
- comment: null
name: messages
using:
foreign_key_constraint_on:
column: chat_id
table: message
delete_permissions:
- comment: null
permission:
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
- owner_id:
_is_null: true
columns:
- created_at
- id
- name
- owner_id
- picture
set: {}
role: user
object_relationships:
- comment: null
name: owner
using:
foreign_key_constraint_on: owner_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- created_at
- id
- name
- owner_id
- picture
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: chat
update_permissions:
- comment: null
permission:
columns:
- created_at
- id
- name
- owner_id
- picture
filter:
_or:
- owner_id:
_eq: X-Hasura-User-Id
- users:
user_id:
_eq: X-Hasura-User-Id
set: {}
role: user
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter:
_or:
- user_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- chat_id
- user_id
set: {}
role: user
object_relationships:
- comment: null
name: chat
using:
foreign_key_constraint_on: chat_id
- comment: null
name: user
using:
foreign_key_constraint_on: user_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- user_id
filter:
_or:
- user_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: chat_users
update_permissions: []
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter:
id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
id:
_eq: X-Hasura-User-Id
columns:
- created_at
- id
- name
- password
- picture
- username
set: {}
role: user
object_relationships: []
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- created_at
- id
- name
- password
- picture
- username
filter: {}
role: user
- comment: null
permission:
allow_aggregations: false
columns:
- created_at
- id
- name
- password
- picture
- username
filter:
id:
_eq: X-Hasura-User-Id
role: mine
table: users
update_permissions:
- comment: null
permission:
columns:
- created_at
- id
- name
- password
- picture
- username
filter:
id:
_eq: X-Hasura-User-Id
set: {}
role: user
- array_relationships: []
delete_permissions:
- comment: null
permission:
filter: {}
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check: {}
columns:
- id
- received_at
- read_at
- user_id
- message_id
set: {}
role: user
object_relationships:
- comment: null
name: user
using:
foreign_key_constraint_on: user_id
- comment: null
name: message
using:
foreign_key_constraint_on: message_id
select_permissions:
- comment: null
permission:
allow_aggregations: false
columns:
- id
- message_id
- read_at
- received_at
- user_id
filter: {}
role: user
table: recipient
update_permissions:
- comment: null
permission:
columns:
- id
- message_id
- read_at
- received_at
- user_id
filter: {}
set: {}
role: user
- array_relationships:
- comment: null
name: recipients
using:
foreign_key_constraint_on:
column: message_id
table: recipient
delete_permissions:
- comment: null
permission:
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
role: user
event_triggers: []
insert_permissions:
- comment: null
permission:
check:
_or:
- sender_id:
_eq: X-Hasura-User-Id
columns:
- id
- content
- created_at
- sender_id
- chat_id
set: {}
role: user
object_relationships:
- comment: null
name: chat
using:
foreign_key_constraint_on: chat_id
- comment: null
name: sender
using:
foreign_key_constraint_on: sender_id
select_permissions:
- comment: null
permission:
allow_aggregations: true
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
- chat:
users:
user_id:
_eq: X-Hasura-User-Id
role: user
table: message
update_permissions:
- comment: null
permission:
columns:
- chat_id
- content
- created_at
- id
- sender_id
filter:
_or:
- sender_id:
_eq: X-Hasura-User-Id
set: {}
role: user

View File

@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env

View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 Uri Goldshtein
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,36 @@
# WhatsApp Clone React Client
### Run instructions
Make sure to setup Hasura GraphQL Engine first.
####Install Dependencies
yarn install
Run codegen to generate TypeScript types
yarn generate
**Note**: The types are generated from the server! So if you have `admin secret` enabled in your graphql-engine server, make sure to update the headers in `codegen.yml` file.
Set environment variables. Open `.env` file and add the following env
```bash
REACT_APP_SERVER_URL='<graphql_engine_server_url'>
REACT_APP_AUTH_URL='<auth_server_url'>
REACT_APP_ENV='dev'
```
####Start the app
```
yarn start
```
Note that the auth server should run on port `8010`. If you decide to change that, be sure to edit the `.env` file in the auth server.
### License
MIT

View File

@ -0,0 +1,6 @@
require('ts-node').register({
transpileOnly: true,
compilerOptions: {
module: 'commonjs'
}
})

View File

@ -0,0 +1,13 @@
schema:
- http://localhost:8080/v1alpha1/graphql:
#headers:
# x-hasura-admin-secret:
documents:
- ./src/**/*.tsx
- ./src/**/*.ts
overwrite: true
generates:
./src/graphql/types.ts:
plugins:
- typescript-common
- typescript-client

View File

@ -0,0 +1,10 @@
{
"name":"whatsapp-clone",
"version": 1,
"env": {
"REACT_APP_SERVER_URL": "https://whatsapp-clone-hasura.herokuapp.com/v1alpha1/graphql",
"REACT_APP_AUTH_URL": "https://warm-hamlet-82072.herokuapp.com"
},
"public": true,
"alias": ["whatsapp-clone-hasura.now.sh"]
}

View File

@ -0,0 +1,61 @@
{
"name": "whatsapp-clone-client",
"version": "0.1.0",
"private": true,
"repository": {
"type": "git",
"url": "https://Urigo@github.com/Urigo/WhatsApp-Clone-Client-React.git"
},
"dependencies": {
"@material-ui/core": "3.9.2",
"@material-ui/icons": "3.0.2",
"@types/moment": "2.13.0",
"apollo-cache-inmemory": "1.4.3",
"apollo-client": "2.4.13",
"apollo-link": "1.2.8",
"apollo-link-context": "1.0.14",
"apollo-link-http": "1.5.11",
"apollo-link-ws": "1.0.14",
"apollo-utilities": "1.1.3",
"graphql": "14.1.1",
"graphql-tag": "2.10.1",
"moment": "2.24.0",
"react": "16.8.3",
"react-apollo-hooks": "0.4.1",
"react-dom": "16.8.3",
"react-fast-compare": "2.0.4",
"react-router-dom": "4.3.1",
"react-router-transition": "1.2.1",
"react-scripts": "2.1.5",
"styled-components": "4.1.3",
"subscriptions-transport-ws": "0.9.15",
"uniqid": "5.0.3"
},
"devDependencies": {
"@types/graphql": "14.0.7",
"@types/node": "11.9.5",
"concurrently": "4.1.0",
"graphql-code-generator": "0.18.0",
"graphql-codegen-typescript-client": "0.18.0",
"graphql-codegen-typescript-common": "0.18.0",
"nodemon": "1.18.10",
"ts-node": "^8.0.2"
},
"scripts": {
"start": "concurrently \"yarn generate:watch\" \"react-scripts start\"",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"generate": "gql-gen",
"generate:watch": "nodemon --exec yarn generate -e graphql"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>WhatsApp Clone</title>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-129818961-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-129818961-1');
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,14 @@
{
"extends": [
"config:base",
":automergeMajor"
],
"baseBranches": [
"master-step1",
"master-step2",
"master-step3",
"master-step4"
],
"prHourlyLimit": 60,
"recreateClosed": true
}

View File

@ -0,0 +1,32 @@
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 40vmin;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@ -0,0 +1,31 @@
import * as React from 'react'
import { BrowserRouter, Route, Redirect } from 'react-router-dom'
import ChatRoomScreen from './components/ChatRoomScreen'
import NewChatScreen from './components/NewChatScreen'
import AnimatedSwitch from './components/AnimatedSwitch'
import AuthScreen from './components/AuthScreen'
import ChatsListScreen from './components/ChatsListScreen'
import GroupDetailsScreen from './components/GroupDetailsScreen'
import SettingsScreen from './components/SettingsScreen'
import NewGroupScreen from './components/NewGroupScreen'
import { withAuth } from './services/auth.service'
const RedirectToChats = () => (
<Redirect to="/chats" />
)
export default () => (
<BrowserRouter>
<AnimatedSwitch>
<Route exact path="/sign-(in|up)" component={AuthScreen} />
<Route exact path="/chats" component={withAuth(ChatsListScreen)} />
<Route exact path="/settings" component={withAuth(SettingsScreen)} />
<Route exact path="/chats/:chatId" component={withAuth(ChatRoomScreen)} />
<Route exact path="/new-chat" component={withAuth(NewChatScreen)} />
<Route exact path="/new-chat/group" component={withAuth(NewGroupScreen)} />
<Route exact path="/new-chat/group/details" component={withAuth(GroupDetailsScreen)} />
<Route exact path="/chats/:chatId/details" component={withAuth(GroupDetailsScreen)} />
<Route component={RedirectToChats} />
</AnimatedSwitch>
</BrowserRouter>
)

View File

@ -0,0 +1,55 @@
import { InMemoryCache } from 'apollo-cache-inmemory'
import { ApolloClient } from 'apollo-client'
import { ApolloLink, split } from 'apollo-link'
import { setContext } from 'apollo-link-context'
import { HttpLink } from 'apollo-link-http'
import { WebSocketLink } from 'apollo-link-ws'
import { getMainDefinition } from 'apollo-utilities'
import { OperationDefinitionNode } from 'graphql'
import { getAuthHeader } from './services/auth.service'
const httpUri = process.env.REACT_APP_SERVER_URL
const wsUri = httpUri.replace(/^https?/, process.env.REACT_APP_ENV === 'dev' ? 'ws' : 'wss')
const httpLink = new HttpLink({
uri: httpUri,
})
const wsLink = new WebSocketLink({
uri: wsUri,
options: {
lazy: true,
reconnect: true,
connectionParams: () => {
return { headers: {'Authorization': getAuthHeader()} };
},
},
})
const authLink = setContext((_, { headers }) => {
const auth = getAuthHeader()
return {
headers: {
...headers,
Authorization: auth,
},
}
})
const terminatingLink = split(
({ query }) => {
const { kind, operation } = getMainDefinition(query) as OperationDefinitionNode
return kind === 'OperationDefinition' && operation === 'subscription'
},
wsLink,
authLink.concat(httpLink),
)
const link = ApolloLink.from([terminatingLink])
const cache = new InMemoryCache()
export default new ApolloClient({
link,
cache,
})

View File

@ -0,0 +1,30 @@
import styled from 'styled-components'
import { AnimatedSwitch, spring } from 'react-router-transition'
const glide = val =>
spring(val, {
stiffness: 174,
damping: 24,
})
const mapStyles = styles => ({
transform: `translateX(${styles.offset}%)`,
})
export default styled(AnimatedSwitch).attrs(() => ({
atEnter: { offset: 100 },
atLeave: { offset: glide(-100) },
atActive: { offset: glide(0) },
mapStyles,
}))`
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
> div {
position: absolute;
overflow: hidden;
width: 100%;
height: 100%;
}
`

View File

@ -0,0 +1,84 @@
import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
import { History } from 'history'
import * as React from 'react'
import { useState } from 'react'
import { signIn } from '../../services/auth.service'
interface SignInFormProps {
history: History
}
export default ({ history }: SignInFormProps) => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const onUsernameChange = ({ target }) => {
setError('')
setUsername(target.value)
}
const onPasswordChange = ({ target }) => {
setError('')
setPassword(target.value)
}
const maySignIn = () => {
return !!(username && password)
}
const handleSignIn = () => {
signIn({ username, password })
.then(() => {
history.push('/chats')
})
.catch(error => {
setError(error.message || error)
})
}
const handleSignUp = () => {
history.push('/sign-up')
}
return (
<div className="SignInForm Screen">
<form>
<legend>Sign in</legend>
<div style={{ width: '100%' }}>
<TextField
className="AuthScreen-text-field"
label="Username"
value={username}
onChange={onUsernameChange}
margin="normal"
placeholder="Enter your username"
/>
<TextField
className="AuthScreen-text-field"
label="Password"
type="password"
value={password}
onChange={onPasswordChange}
margin="normal"
placeholder="Enter your password"
/>
</div>
<Button
type="button"
color="secondary"
variant="contained"
disabled={!maySignIn()}
onClick={handleSignIn}
>
Sign in
</Button>
<div className="AuthScreen-error">{error}</div>
<span className="AuthScreen-alternative">
Don't have an account yet? <a onClick={handleSignUp}>Sign up!</a>
</span>
</form>
</div>
)
}

View File

@ -0,0 +1,131 @@
import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
import { History } from 'history'
import * as React from 'react'
import { useState } from 'react'
import { signUp } from '../../services/auth.service'
interface SignUpFormProps {
history: History
}
export default ({ history }: SignUpFormProps) => {
const [name, setName] = useState('')
const [username, setUsername] = useState('')
const [oldPassword, setOldPassword] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const updateName = ({ target }) => {
setError('')
setName(target.value)
}
const updateUsername = ({ target }) => {
setError('')
setUsername(target.value)
}
const updateOldPassword = ({ target }) => {
setError('')
setOldPassword(target.value)
}
const updateNewPassword = ({ target }) => {
setError('')
setPassword(target.value)
}
const maySignUp = () => {
return !!(name && username && oldPassword && oldPassword === password)
}
const handleSignUp = () => {
signUp({ username, password, name })
.then((response) => {
if(response.ok) {
history.push('/sign-in')
} else {
alert('Could not signup now. Try again later');
}
})
.catch(error => {
setError(error.message || error)
})
}
const handleSignIn = () => {
history.push('/sign-in')
}
return (
<div className="SignUpForm Screen">
<form>
<legend>Sign up</legend>
<div
style={{
float: 'left',
width: 'calc(50% - 10px)',
paddingRight: '10px',
}}
>
<TextField
className="AuthScreen-text-field"
label="Name"
value={name}
onChange={updateName}
autoComplete="off"
margin="normal"
/>
<TextField
className="AuthScreen-text-field"
label="Username"
value={username}
onChange={updateUsername}
autoComplete="off"
margin="normal"
/>
</div>
<div
style={{
float: 'right',
width: 'calc(50% - 10px)',
paddingLeft: '10px',
}}
>
<TextField
className="AuthScreen-text-field"
label="Enter password"
type="password"
value={oldPassword}
onChange={updateOldPassword}
autoComplete="off"
margin="normal"
/>
<TextField
className="AuthScreen-text-field"
label="Confirm password"
type="password"
value={password}
onChange={updateNewPassword}
autoComplete="off"
margin="normal"
/>
</div>
<Button
type="button"
color="secondary"
variant="contained"
disabled={!maySignUp()}
onClick={handleSignUp}
>
Sign up
</Button>
<div className="AuthScreen-error">{error}</div>
<span className="AuthScreen-alternative">
Already have an accout? <a onClick={handleSignIn}>Sign in!</a>
</span>
</form>
</div>
)
}

View File

@ -0,0 +1,118 @@
import * as React from 'react'
import { RouteComponentProps } from 'react-router-dom'
import { Route } from 'react-router-dom'
import styled from 'styled-components'
import AnimatedSwitch from '../AnimatedSwitch'
import SignInForm from './SignInForm'
import SignUpForm from './SignUpForm'
const Style = styled.div`
background: radial-gradient(rgb(34, 65, 67), rgb(17, 48, 50)),
url(/assets/chat-background.jpg) no-repeat;
background-size: cover;
background-blend-mode: multiply;
color: white;
.AuthScreen-intro {
height: 265px;
}
.AuthScreen-icon {
width: 125px;
height: auto;
margin-left: auto;
margin-right: auto;
padding-top: 70px;
display: block;
}
.AuthScreen-title {
width: 100%;
text-align: center;
color: white;
}
.AuthScreen-text-field {
width: 100%;
position: relative;
}
.AuthScreen-text-field > div::before {
border-color: white !important;
}
.AuthScreen-error {
position: absolute;
color: red;
font-size: 15px;
margin-top: 20px;
}
.AuthScreen-alternative {
position: absolute;
bottom: 10px;
left: 10px;
a {
color: var(--secondary-bg);
}
}
.Screen {
height: calc(100% - 265px);
}
form {
padding: 20px;
> div {
padding-bottom: 35px;
}
}
legend {
font-weight: bold;
color: white;
}
label {
color: white !important;
}
input {
color: white;
&::placeholder {
color: var(--primary-bg);
}
}
button {
width: 100px;
display: block;
margin-left: auto;
margin-right: auto;
background-color: var(--secondary-bg) !important;
&[disabled] {
color: #38a81c;
}
&:not([disabled]) {
color: white;
}
}
`
export default ({ history, location }: RouteComponentProps) => (
<Style className="AuthScreen Screen">
<div className="AuthScreen-intro">
<img src="assets/whatsapp-icon.png" className="AuthScreen-icon" />
<h2 className="AuthScreen-title">WhatsApp Clone</h2>
</div>
<AnimatedSwitch>
<Route exact path="/sign-in" component={SignInForm} />
<Route exact path="/sign-up" component={SignUpForm} />
</AnimatedSwitch>
</Style>
)

View File

@ -0,0 +1,214 @@
import Button from '@material-ui/core/Button'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import Popover from '@material-ui/core/Popover'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import DeleteIcon from '@material-ui/icons/Delete'
import InfoIcon from '@material-ui/icons/Info'
import MoreIcon from '@material-ui/icons/MoreVert'
import gql from 'graphql-tag'
import { History } from 'history'
import * as React from 'react'
import { useState } from 'react'
import { useQuery, useMutation } from 'react-apollo-hooks'
import styled from 'styled-components'
import * as fragments from '../../graphql/fragments'
import * as queries from '../../graphql/queries'
import { useMe } from '../../services/auth.service';
import { ChatList, DeleteChat, ChatsListQueryCache } from '../../graphql/types'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
margin-left: -20px;
.ChatNavbar-title {
line-height: 56px;
}
.ChatNavbar-back-button {
color: var(--primary-text);
}
.ChatNavbar-picture {
height: 40px;
width: 40px;
margin-top: 3px;
margin-left: -22px;
object-fit: cover;
padding: 5px;
border-radius: 50%;
}
.ChatNavbar-rest {
flex: 1;
justify-content: flex-end;
}
.ChatNavbar-options-btn {
float: right;
height: 100%;
font-size: 1.2em;
margin-right: -15px;
color: var(--primary-text);
}
.ChatNavbar-options-item svg {
margin-right: 10px;
padding-left: 15px;
}
`
const query = gql`
query ChatList($chatId: Int!, $userId: Int!) {
chat_users(where:{chat_id: {_eq: $chatId}, user_id: {_neq: $userId}}) {
chat {
...chat
}
user {
...user
}
}
}
${fragments.chat}
${fragments.user}
`
const queryCache = gql`
query ChatsListQueryCache($userId: Int!) {
chat(order_by:[{messages_aggregate:{max:{created_at:desc}}}]) {
...chat
users(where:{user_id:{_neq:$userId}}) {
user {
...user
}
}
}
}
${fragments.chat}
${fragments.user}
`;
const mutation = gql`
mutation deleteChat($chatId: Int!) {
delete_chat_users(where:{chat_id:{_eq: $chatId}}) {
affected_rows
}
delete_message(where:{chat_id:{_eq: $chatId}}) {
affected_rows
}
delete_chat(where:{id: {_eq: $chatId}}) {
affected_rows
}
}
`
interface ChatNavbarProps {
chatId: string
history: History
}
export default ({ chatId, history }: ChatNavbarProps) => {
const me = useMe();
const parsedChatId = parseInt(chatId,10)
const {
data: { chat_users },
} = useQuery<ChatList.Query, ChatList.Variables>(query, {
variables: { chatId: parsedChatId, userId: me.id },
suspend: true,
})
const removeChat = useMutation<DeleteChat.Mutation, DeleteChat.Variables>(
mutation,
{
variables: { chatId: parsedChatId },
update: (client, { data: { delete_chat } }) => {
let chats
try {
chats = client.readQuery<ChatsListQueryCache.Query, ChatsListQueryCache.Variables>({
query: queryCache,
variables: {userId: me.id}
}).chat
} catch(e) {
console.error(e)
}
if (chats) {
// filter current parsedChatId
chats = chats.filter((chat) => chat.id !== parsedChatId);
try {
client.writeQuery<ChatsListQueryCache.Query, ChatsListQueryCache.Variables>({
query: queryCache,
variables: {userId: me.id},
data: { chat: chats },
})
} catch(e) {
console.error(e)
}
}
},
},
)
const [popped, setPopped] = useState(false)
const navToChats = () => {
history.push('/chats')
}
const navToGroupDetails = () => {
setPopped(false)
history.push(`/chats/${chatId}/details`, { chat_users })
}
const handleRemoveChat = () => {
setPopped(false)
removeChat().then(navToChats)
}
let picture = chat_users[0].chat.owner_id ? chat_users[0].chat.picture : chat_users[0].user.picture;
if(!picture) {
picture = chat_users[0].chat.owner_id ? '/assets/default-group-pic.jpg' : '/assets/default-profile-pic.jpg';
}
return (
<Style className={name}>
<Button className="ChatNavbar-back-button" onClick={navToChats}>
<ArrowBackIcon />
</Button>
<img
className="ChatNavbar-picture"
src={picture}
/>
<div className="ChatNavbar-title">{chat_users[0].chat.owner_id ? chat_users[0].chat.name : chat_users[0].user.username}</div>
<div className="ChatNavbar-rest">
<Button className="ChatNavbar-options-btn" onClick={setPopped.bind(null, true)}>
<MoreIcon />
</Button>
</div>
<Popover
open={popped}
onClose={setPopped.bind(null, false)}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Style style={{ marginLeft: '-15px' }}>
<List>
{chat_users[0].chat.owner_id && (
<ListItem className="ChatNavbar-options-item" button onClick={navToGroupDetails}>
<InfoIcon />
Details
</ListItem>
)}
<ListItem className="ChatNavbar-options-item" button onClick={handleRemoveChat}>
<DeleteIcon />
Delete
</ListItem>
</List>
</Style>
</Popover>
</Style>
)
}

View File

@ -0,0 +1,165 @@
import Button from '@material-ui/core/Button'
import SendIcon from '@material-ui/icons/Send'
import gql from 'graphql-tag'
import * as React from 'react'
import { useState } from 'react'
import { useMutation } from 'react-apollo-hooks'
import styled from 'styled-components'
import { time as uniqid } from 'uniqid'
import * as fragments from '../../graphql/fragments'
import { MessageBoxMutation, MessagesListQueryLocal } from '../../graphql/types'
import { useMe } from '../../services/auth.service'
const Style = styled.div`
display: flex;
height: 50px;
padding: 5px;
width: calc(100% - 10px);
.MessageBox-input {
width: calc(100% - 50px);
border: none;
border-radius: 999px;
padding: 10px;
padding-left: 20px;
padding-right: 20px;
font-size: 15px;
outline: none;
box-shadow: 0 1px silver;
font-size: 18px;
line-height: 45px;
}
.MessageBox-button {
min-width: 50px;
width: 50px;
border-radius: 999px;
background-color: var(--primary-bg);
margin: 0 5px;
margin-right: 0;
color: white;
padding-left: 20px;
svg {
margin-left: -3px;
}
}
`
const mutation = gql`
mutation MessageBoxMutation($chatId: Int!, $content: String!, $sender_id: Int!) {
insert_message(objects: [{chat_id: $chatId, content: $content, sender_id: $sender_id}]) {
affected_rows
returning {
id
content
created_at
sender_id
chat_id
chat {
id
}
sender {
id
name
}
recipients {
user {
id
}
message {
id
chat {
id
}
}
received_at
read_at
}
}
}
}
`
interface MessageBoxProps {
chatId: string
}
export default ({ chatId }: MessageBoxProps) => {
const [message, setMessage] = useState('')
const me = useMe()
const senderId = me.id;
const addMessage = useMutation<MessageBoxMutation.Mutation, MessageBoxMutation.Variables>(
mutation,
{
variables: {
chatId: parseInt(chatId,10),
content: message,
sender_id: senderId
},
update: (client, { data: { insert_message } }) => {
const chatQuery = gql`
query MessagesListQueryLocal($chatId: Int!) {
chat(where:{id: {_eq: $chatId}}) {
...chat
}
}
${fragments.chat}
`
let chatData
try {
chatData = client.readQuery<MessagesListQueryLocal.Query, MessagesListQueryLocal.Variables>(
{query: chatQuery, variables: {chatId: parseInt(chatId,10)}})
} catch(e) {
console.error(e);
}
const finalChatData = {...chatData};
finalChatData.chat[0].messages.push(insert_message.returning[0]);
try {
client.writeQuery<MessagesListQueryLocal.Query, MessagesListQueryLocal.Variables>(
{query: chatQuery, variables: {chatId: parseInt(chatId,10)}, data: finalChatData})
} catch(e) {
console.error(e);
}
},
},
)
const onKeyPress = e => {
if (e.charCode === 13) {
submitMessage()
}
}
const onChange = ({ target }) => {
setMessage(target.value)
}
const submitMessage = () => {
if (!message) return
addMessage()
setMessage('')
}
return (
<Style className="MessageBox">
<input
className="MessageBox-input"
type="text"
placeholder="Type a message"
value={message}
onKeyPress={onKeyPress}
onChange={onChange}
/>
<Button
variant="contained"
color="primary"
className="MessageBox-button"
onClick={submitMessage}
>
<SendIcon />
</Button>
</Style>
)
}

View File

@ -0,0 +1,149 @@
import gql from 'graphql-tag'
import * as moment from 'moment'
import * as React from 'react'
import { useRef, useEffect } from 'react'
import { useQuery, useMutation } from 'react-apollo-hooks'
import * as ReactDOM from 'react-dom'
import styled from 'styled-components'
import * as fragments from '../../graphql/fragments'
import { useMe } from '../../services/auth.service'
import { MessagesListQuery } from '../../graphql/types'
const Style = styled.div`
display: block;
height: calc(100% - 60px);
width: calc(100% - 30px);
overflow-y: overlay;
padding: 0 15px;
.MessagesList-message {
display: inline-block;
position: relative;
max-width: 100%;
border-radius: 7px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
margin-top: 10px;
margin-bottom: 10px;
clear: both;
&::after {
content: '';
display: table;
clear: both;
}
}
.MessagesList-message-mine {
float: right;
background-color: #dcf8c6;
&::before {
right: -11px;
background-image: url(/assets/message-mine.png);
}
}
.MessagesList-message-others {
float: left;
background-color: #fff;
&::before {
left: -11px;
background-image: url(/assets/message-other.png);
}
}
.MessagesList-message-others::before,
.MessagesList-message-mine::before {
content: '';
position: absolute;
bottom: 3px;
width: 12px;
height: 19px;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: contain;
}
.MessagesList-message-sender {
font-weight: bold;
margin-left: 5px;
margin-top: 5px;
}
.MessagesList-message-contents {
padding: 5px 7px;
word-wrap: break-word;
&::after {
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
display: inline;
}
}
.MessagesList-message-timestamp {
position: absolute;
bottom: 2px;
right: 7px;
color: gray;
font-size: 12px;
}
`
const query = gql`
query MessagesListQuery($chatId: Int!) {
chat(where:{id: {_eq: $chatId}}) {
...chat
}
}
${fragments.chat}
`
interface MessagesListProps {
chatId: string
}
export default ({ chatId }: MessagesListProps) => {
const me = useMe();
const {
data
} = useQuery<MessagesListQuery.Query, MessagesListQuery.Variables>(query, {
variables: { chatId: parseInt(chatId,10) },
suspend: true,
})
const messages = data.chat[0].messages;
const owner_id = data.chat[0].owner_id;
const selfRef = useRef(null)
const resetScrollTop = () => {
if (!selfRef.current) return
const selfDOMNode = ReactDOM.findDOMNode(selfRef.current) as HTMLElement
selfDOMNode.scrollTop = Number.MAX_SAFE_INTEGER
}
useEffect(resetScrollTop, [selfRef.current])
useEffect(resetScrollTop, [messages.length])
return (
<Style className={name} ref={selfRef}>
{messages &&
messages.map(message => (
<div
key={message.id+message.created_at}
className={`MessagesList-message ${
message.sender.id === me.id ? 'MessagesList-message-mine' : 'MessagesList-message-others'
}`}
>
{owner_id && (
<div className="MessagesList-message-sender">{message.sender.name}</div>
)}
<div className="MessagesList-message-contents">{message.content}</div>
<span className="MessagesList-message-timestamp">
{moment(message.created_at).format('HH:mm')}
</span>
</div>
))}
</Style>
)
}

View File

@ -0,0 +1,56 @@
import * as React from 'react'
import { Suspense } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import styled from 'styled-components'
import Navbar from '../Navbar'
import ChatNavbar from './ChatNavbar'
import MessageBox from './MessageBox'
import MessagesList from './MessagesList'
const Style = styled.div`
.ChatScreen-body {
position: relative;
background: url(/assets/chat-background.jpg);
width: 100%;
height: calc(100% - 56px);
.MessagesList {
position: absolute;
height: calc(100% - 60px);
top: 0;
}
.MessageBox {
position: absolute;
bottom: 0;
left: 0;
}
.AddChatButton {
right: 0;
bottom: 0;
}
}
`
export default ({ match, history }: RouteComponentProps) => {
const chatId = match.params.chatId
return (
<Style className="ChatScreen Screen">
<Navbar>
<Suspense fallback={null}>
<ChatNavbar chatId={chatId} history={history} />
</Suspense>
</Navbar>
<div className="ChatScreen-body">
<Suspense fallback={null}>
<MessagesList chatId={chatId} />
</Suspense>
<Suspense fallback={null}>
<MessageBox chatId={chatId} />
</Suspense>
</div>
</Style>
)
}

View File

@ -0,0 +1,38 @@
import Button from '@material-ui/core/Button'
import ChatIcon from '@material-ui/icons/Chat'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
position: fixed;
right: 10px;
bottom: 10px;
button {
min-width: 50px;
width: 50px;
height: 50px;
border-radius: 999px;
background-color: var(--secondary-bg);
color: white;
}
`
interface AddChatButtonProps {
history: History
}
export default ({ history }: AddChatButtonProps) => {
const onClick = () => {
history.push('/new-chat')
}
return (
<Style className="AddChatButton">
<Button variant="contained" color="secondary" onClick={onClick}>
<ChatIcon />
</Button>
</Style>
)
}

View File

@ -0,0 +1,132 @@
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import gql from 'graphql-tag'
import { History } from 'history'
import * as moment from 'moment'
import * as React from 'react'
import { useQuery } from 'react-apollo-hooks'
import * as ReactDOM from 'react-dom'
import styled from 'styled-components'
import * as fragments from '../../graphql/fragments'
import { ChatsListQuery } from '../../graphql/types'
import { useMe } from '../../services/auth.service';
const Style = styled.div`
height: calc(100% - 56px);
overflow-y: overlay;
.ChatsList-chats-list {
padding: 0;
}
.ChatsList-chat-item {
height: 76px;
padding: 0 15px;
display: flex;
}
.ChatsList-profile-pic {
height: 50px;
width: 50px;
object-fit: cover;
border-radius: 50%;
}
.ChatsList-info {
width: calc(100% - 60px);
height: calc(100% - 30px);
padding: 15px 0;
margin-left: 10px;
border-bottom: 0.5px solid silver;
position: relative;
}
.ChatsList-name {
margin-top: 5px;
}
.ChatsList-last-message {
color: gray;
font-size: 15px;
margin-top: 5px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.ChatsList-timestamp {
position: absolute;
color: gray;
top: 20px;
right: 0;
font-size: 13px;
}
`
// user_id not equal to currently logged in user
const query = gql`
query ChatsListQuery($userId: Int!) {
chat(order_by:[{messages_aggregate:{max:{created_at:desc}}}]) {
...chat
users(where:{user_id:{_neq:$userId}}) {
user {
...user
}
}
}
}
${fragments.chat}
${fragments.user}
`
interface ChatsListProps {
history: History
}
export default ({ history }: ChatsListProps) => {
const me = useMe();
const {
data: { chat },
} = useQuery<ChatsListQuery.Query, ChatsListQuery.Variables>(query, { variables: {userId: me.id}, suspend: true })
const navToChat = chatId => {
history.push(`chats/${chatId}`)
}
return (
<Style className="ChatsList">
<List className="ChatsList-chats-list">
{chat.map(chat => {
return (
<ListItem
key={chat.id}
className="ChatsList-chat-item"
button
onClick={navToChat.bind(null, chat.id)}
>
<img
className="ChatsList-profile-pic"
src={
chat.users[0].user.picture ||
(chat.owner_id
? '/assets/default-group-pic.jpg'
: '/assets/default-profile-pic.jpg')
}
/>
<div className="ChatsList-info">
<div className="ChatsList-name">{chat.owner_id ? chat.name : chat.users[0].user.username}</div>
{chat.messages && chat.messages[chat.messages.length-1] && (
<React.Fragment>
<div className="ChatsList-last-message">{chat.messages[chat.messages.length-1].content}</div>
<div className="ChatsList-timestamp">
{moment(chat.messages[chat.messages.length-1].created_at).format('HH:mm')}
</div>
</React.Fragment>
)}
</div>
</ListItem>
);
})}
</List>
</Style>
)
}

View File

@ -0,0 +1,95 @@
import Button from '@material-ui/core/Button'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import Popover from '@material-ui/core/Popover'
import MoreIcon from '@material-ui/icons/MoreVert'
import SignOutIcon from '@material-ui/icons/PowerSettingsNew'
import SettingsIcon from '@material-ui/icons/Settings'
import { History } from 'history'
import * as React from 'react'
import { useState } from 'react'
import styled from 'styled-components'
import { signOut } from '../../services/auth.service'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
.ChatsNavbar-title {
line-height: 56px;
}
.ChatsNavbar-options-btn {
float: right;
height: 100%;
font-size: 1.2em;
margin-right: -15px;
color: var(--primary-text);
}
.ChatsNavbar-rest {
flex: 1;
justify-content: flex-end;
}
.ChatsNavbar-options-item svg {
margin-right: 10px;
}
`
interface ChatsNavbarProps {
history: History
}
export default ({ history }: ChatsNavbarProps) => {
const [popped, setPopped] = useState(false)
const navToSettings = () => {
setPopped(false)
history.push('/settings')
}
const handleSignOut = () => {
setPopped(false)
signOut()
history.push('/sign-in')
}
return (
<Style className="ChatsNavbar">
<span className="ChatsNavbar-title">WhatsApp Clone</span>
<div className="ChatsNavbar-rest">
<Button className="ChatsNavbar-options-btn" onClick={setPopped.bind(null, true)}>
<MoreIcon />
</Button>
</div>
<Popover
open={popped}
onClose={setPopped.bind(null, false)}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Style>
<List>
<ListItem className="ChatsNavbar-options-item" button onClick={navToSettings}>
<SettingsIcon />
Settings
</ListItem>
<ListItem className="ChatsNavbar-options-item" button onClick={handleSignOut}>
<SignOutIcon />
Sign Out
</ListItem>
</List>
</Style>
</Popover>
</Style>
)
}

View File

@ -0,0 +1,19 @@
import * as React from 'react'
import { Suspense } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import Navbar from '../Navbar'
import AddChatButton from './AddChatButton'
import ChatsList from './ChatsList'
import ChatsNavbar from './ChatsNavbar'
export default ({ history }: RouteComponentProps) => (
<div className="ChatsListScreen Screen">
<Navbar>
<ChatsNavbar history={history} />
</Navbar>
<Suspense fallback={null}>
<ChatsList history={history} />
</Suspense>
<AddChatButton history={history} />
</div>
)

View File

@ -0,0 +1,107 @@
import Button from '@material-ui/core/Button'
import ArrowRightIcon from '@material-ui/icons/ArrowRightAlt'
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'
import gql from 'graphql-tag'
import { History } from 'history'
import * as React from 'react'
import { useMutation } from 'react-apollo-hooks'
import styled from 'styled-components'
import { time as uniqid } from 'uniqid'
import * as fragments from '../../graphql/fragments'
import * as queries from '../../graphql/queries'
import { CompleteGroupButtonMutation, Chat, Chats } from '../../graphql/types'
import { useMe } from '../../services/auth.service'
const Style = styled.div`
position: fixed;
right: 10px;
bottom: 10px;
button {
min-width: 50px;
width: 50px;
height: 50px;
border-radius: 999px;
background-color: var(--secondary-bg);
color: white;
}
`
const mutation = gql`
mutation CompleteGroupButtonMutation(
$userIds: [chat_users_insert_input!]!
$groupName: String!
$groupPicture: String
$ownerId: Int
) {
insert_chat(objects: [{name: $groupName, picture: $groupPicture, owner_id: $ownerId, users:{data: $userIds}}]) {
returning {
...chat
}
}
}
${fragments.chat}
`
interface CompleteGroupButtonProps {
history: History
users: any
groupName: string
groupPicture: string
}
export default ({ history, users, groupName, groupPicture }: CompleteGroupButtonProps) => {
const me = useMe()
// business logic required.
const userIds = users.map(user => { return {user_id: user.id} });
userIds.push({user_id: me.id});
const addGroup = useMutation<CompleteGroupButtonMutation.Mutation, CompleteGroupButtonMutation.Variables>(mutation, {
variables: {
userIds: userIds,
groupName,
groupPicture,
ownerId: me.id
},
update: (client, { data: { insert_chat } }) => {
client.writeFragment<Chat.Fragment>({
id: defaultDataIdFromObject(insert_chat.returning[0]),
fragment: fragments.chat,
fragmentName: 'chat',
data: insert_chat.returning[0],
})
let chats
try {
chats = client.readQuery<Chats.Query>({
query: queries.chats,
}).chat
} catch (e) {}
if (chats && !chats.some(chat => chat.id === insert_chat.returning[0].id)) {
chats.unshift(insert_chat.returning[0])
client.writeQuery<Chats.Query>({
query: queries.chats,
data: { chat: chats },
})
}
// now insert group members chat_users
},
})
const onClick = () => {
addGroup().then(({ data: { insert_chat } }) => {
history.push(`/chats/${insert_chat.returning[0].id}`)
})
}
return (
<Style className="CompleteGroupButton">
<Button variant="contained" color="secondary" onClick={onClick}>
<ArrowRightIcon />
</Button>
</Style>
)
}

View File

@ -0,0 +1,44 @@
import Button from '@material-ui/core/Button'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
margin-left: -20px;
.GroupDetailsNavbar-title {
line-height: 56px;
}
.GroupDetailsNavbar-back-button {
color: var(--primary-text);
}
`
interface GroupDetailsNavbarProps {
history: History
chatId?: string
}
export default ({ history, chatId }: GroupDetailsNavbarProps) => {
const navToNewGroup = () => {
if (chatId) {
history.push(`/chats/${chatId}`)
} else {
history.push('/new-chat/group')
}
}
return (
<Style className="GroupDetailsNavbar">
<Button className="GroupDetailsNavbar-back-button" onClick={navToNewGroup}>
<ArrowBackIcon />
</Button>
<div className="GroupDetailsNavbar-title">Group Details</div>
</Style>
)
}

View File

@ -0,0 +1,253 @@
import TextField from '@material-ui/core/TextField'
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'
import gql from 'graphql-tag'
import * as React from 'react'
import { useState, useEffect } from 'react'
import { MutationHookOptions } from 'react-apollo-hooks'
import { useQuery, useMutation } from 'react-apollo-hooks'
import { Redirect } from 'react-router-dom'
import { RouteComponentProps } from 'react-router-dom'
import styled from 'styled-components'
import * as fragments from '../../graphql/fragments'
import { GroupDetailsScreenQuery, GroupDetailsScreenUpdateMutation } from '../../graphql/types'
import { useMe } from '../../services/auth.service'
import { pickPicture, uploadProfilePicture } from '../../services/picture.service'
import Navbar from '../Navbar'
import CompleteGroupButton from './CompleteGroupButton'
import GroupDetailsNavbar from './GroupDetailsNavbar'
const Style = styled.div`
.GroupDetailsScreen-group-name {
width: calc(100% - 30px);
margin: 15px;
}
.GroupDetailsScreen-participants-title {
margin-top: 10px;
margin-left: 15px;
}
.GroupDetailsScreen-participants-list {
display: flex;
overflow: overlay;
padding: 0;
}
.GroupDetailsScreen-participant-item {
padding: 10px;
flex-flow: row wrap;
text-align: center;
}
.GroupDetailsScreen-participant-picture {
flex: 0 1 50px;
height: 50px;
width: 50px;
object-fit: cover;
border-radius: 50%;
display: block;
margin-left: auto;
margin-right: auto;
}
.GroupDetailsScreen-group-info {
display: flex;
flex-direction: row;
align-items: center;
}
.GroupDetailsScreen-participant-name {
line-height: 10px;
font-size: 14px;
}
.GroupDetailsScreen-group-picture {
width: 50px;
flex-basis: 50px;
border-radius: 50%;
margin-left: 15px;
object-fit: cover;
${props => props.ownedByMe && 'cursor: pointer;'}
}
`
const query = gql`
query GroupDetailsScreenQuery($chatId: Int!) {
chat(where:{id: {_eq: $chatId}}) {
...chat
users {
user {
...user
}
}
}
}
${fragments.chat}
${fragments.user}
`
const updateMutation = gql`
mutation GroupDetailsScreenUpdateMutation($name: String, $picture: String, $chatId: Int!) {
update_chat(_set: {name: $name, picture: $picture}, where: {id: {_eq: $chatId}}) {
affected_rows
returning {
...chat
}
}
}
${fragments.chat}
`
export default ({ location, match, history }: RouteComponentProps) => {
const chatId = match.params.chatId
const me = useMe()
let ownedByMe: boolean
let users: any
let participants: any
let updateChat: () => any
let chatNameState
let chatPictureState
// The entire component functionality will be determined by the provided route param
if (chatId) {
const {
data: { chat },
} = useQuery<GroupDetailsScreenQuery.Query, GroupDetailsScreenQuery.Variables>(query, {
variables: { chatId },
suspend: true,
})
ownedByMe = chat[0].owner_id === me.id
users = chat[0].users
participants = users.map((user) => {
return user.user
});
// Read-only if not owned by me
if (ownedByMe) {
chatNameState = useState(chat[0].name)
chatPictureState = useState(chat[0].picture)
} else {
chatNameState = [chat[0].name, () => {}]
chatPictureState = [chat[0].picture, () => {}]
}
const [chatName] = chatNameState
const [chatPicture] = chatPictureState
updateChat = useMutation<GroupDetailsScreenUpdateMutation.Mutation>(updateMutation, {
variables: {
chatId,
name: chatName,
picture: chatPicture,
},
update: (client, { data: { update_chat } }) => {
chat[0].picture = update_chat.returning[0].picture
chat[0].name = update_chat.returning[0].name
client.writeFragment({
id: defaultDataIdFromObject(chat),
fragment: fragments.chat,
fragmentName: 'chat',
data: chat,
})
},
})
// Update picture once changed
useEffect(
() => {
if (chatPicture !== chat[0].picture) {
updateChat()
}
},
[chatPicture],
)
} else {
ownedByMe = true
updateChat = () => {}
chatNameState = useState('')
chatPictureState = useState('')
users = location.state.users
participants = [me].concat(users)
}
// Users are missing from state
if (!(users instanceof Array)) {
return <Redirect to="/chats" />
}
// Put me first
{
const index = participants.findIndex(participant => participant.id === me.id)
participants.splice(index, 1)
participants.unshift(me)
}
const [chatName, setChatName] = chatNameState
const [chatPicture, setChatPicture] = chatPictureState
const updateChatName = ({ target }) => {
setChatName(target.value)
}
const updateChatPicture = async () => {
// You have to be an admin
if (!ownedByMe) return
const file = await pickPicture()
if (!file) return
const { url } = await uploadProfilePicture(file)
setChatPicture(url)
}
return (
<Style className="GroupDetailsScreen Screen" ownedByMe={ownedByMe}>
<Navbar>
<GroupDetailsNavbar chatId={chatId} history={history} />
</Navbar>
<div className="GroupDetailsScreen-group-info">
<img
className="GroupDetailsScreen-group-picture"
src={chatPicture || '/assets/default-group-pic.jpg'}
onClick={updateChatPicture}
/>
<TextField
label="Group name"
placeholder="Enter group name"
className="GroupDetailsScreen-group-name"
value={chatName}
onChange={updateChatName}
onBlur={updateChat}
disabled={!ownedByMe}
autoFocus={true}
/>
</div>
<div className="GroupDetailsScreen-participants-title">
Participants: {participants.length}
</div>
<ul className="GroupDetailsScreen-participants-list">
{participants.map(participant => (
<div key={participant.id} className="GroupDetailsScreen-participant-item">
<img
src={participant.picture || '/assets/default-profile-pic.jpg'}
className="GroupDetailsScreen-participant-picture"
/>
<span className="GroupDetailsScreen-participant-name">{participant.name}</span>
</div>
))}
</ul>
{!chatId && chatName && (
<CompleteGroupButton
history={history}
groupName={chatName}
groupPicture={chatPicture}
users={users}
/>
)}
</Style>
)
}

View File

@ -0,0 +1,24 @@
import Toolbar from '@material-ui/core/Toolbar'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled(Toolbar)`
background-color: var(--primary-bg);
color: var(--primary-text);
font-size: 20px;
line-height: 40px;
.Navbar-body {
width: 100%;
}
`
interface NavbarProps {
children: any
}
export default ({ children }: NavbarProps) => (
<Style className="Navbar">
<div className="Navbar-body">{children}</div>
</Style>
)

View File

@ -0,0 +1,39 @@
import Button from '@material-ui/core/Button'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
margin-left: -20px;
.NewChatNavbar-title {
line-height: 56px;
}
.NewChatNavbar-back-button {
color: var(--primary-text);
}
`
interface NewChatNavbarProps {
history: History
}
export default ({ history }: NewChatNavbarProps) => {
const navToChats = () => {
history.push('/chats')
}
return (
<Style className="NewChatNavbar">
<Button className="NewChatNavbar-back-button" onClick={navToChats}>
<ArrowBackIcon />
</Button>
<div className="NewChatNavbar-title">New Chat</div>
</Style>
)
}

View File

@ -0,0 +1,59 @@
import Button from '@material-ui/core/Button'
import GroupAddIcon from '@material-ui/icons/GroupAdd'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
display: flex;
button {
border-radius: 0;
text-transform: none;
font-size: inherit;
width: 100%;
justify-content: flex-start;
padding-left: 15px;
padding-right: 15px;
svg {
font-size: 30px;
margin-top: 10px;
}
}
.NewGroupButton-icon {
height: 50px;
width: 50px;
object-fit: cover;
border-radius: 50%;
color: white;
background-color: var(--secondary-bg);
}
.NewGroupButton-title {
padding-left: 15px;
font-weight: bold;
}
`
interface NewGroupButtonProps {
history: History
}
export default ({ history }: NewGroupButtonProps) => {
const navToGroup = () => {
history.push('/new-chat/group')
}
return (
<Style>
<Button onClick={navToGroup}>
<div className="NewGroupButton-icon">
<GroupAddIcon />
</div>
<div className="NewGroupButton-title">New Group</div>
</Button>
</Style>
)
}

View File

@ -0,0 +1,121 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'
import gql from 'graphql-tag'
import * as React from 'react'
import { Suspense } from 'react'
import { useQuery, useMutation } from 'react-apollo-hooks'
import { RouteComponentProps } from 'react-router-dom'
import styled from 'styled-components'
import { time as uniqid } from 'uniqid'
import * as fragments from '../../graphql/fragments'
import * as queries from '../../graphql/queries'
import { NewChatScreenMutation, Chats, Chat } from '../../graphql/types'
import { useMe } from '../../services/auth.service'
import Navbar from '../Navbar'
import UsersList from '../UsersList'
import NewChatNavbar from './NewChatNavbar'
import NewGroupButton from './NewGroupButton'
const Style = styled.div`
.UsersList {
height: calc(100% - 56px);
}
.NewChatScreen-users-list {
height: calc(100% - 56px);
overflow-y: overlay;
}
`
const mutation = gql`
mutation NewChatScreenMutation($userId: Int!,$currentUserId: Int!) {
insert_chat(objects: [{
owner_id: null,
users: {
data: [
{user_id: $userId},
{user_id: $currentUserId}
]
}
}]) {
affected_rows
returning {
...chat
}
}
}
${fragments.chat}
`;
export default ({ history }: RouteComponentProps) => {
const me = useMe();
const addChat = useMutation<NewChatScreenMutation.Mutation, NewChatScreenMutation.Variables>(
mutation,
{
update: (client, { data: { insert_chat } }) => {
try {
client.writeFragment<Chat.Fragment>({
id: defaultDataIdFromObject(insert_chat.returning[0]),
fragment: fragments.chat,
fragmentName: 'chat',
data: insert_chat.returning[0],
})
} catch(e) {
console.error(e)
}
let chats
try {
chats = client.readQuery<Chats.Query>({
query: queries.chats,
})
} catch (e) {
console.error(e);
}
if (chats && chats.length && !chats.some(chat => chat.id === insert_chat.returning[0].id)) {
// move new chat to first
chats.unshift(insert_chat.returning[0])
client.writeQuery<Chats.Query>({
query: queries.chats,
data: { chat: chats },
})
}
},
},
);
const onUserPick = user => {
const selectedUserId = user.id;
if(user.isExisting) {
history.push(`/chats/${user.chat_id}`)
} else {
addChat({
variables: {
userId: user.id,
currentUserId: me.id
},
})
.then(({ data: { insert_chat } }) => {
history.push(`/chats/${insert_chat.returning[0].id}`)
})
}
}
return (
<Style className="NewChatScreen Screen">
<Navbar>
<NewChatNavbar history={history} />
</Navbar>
<div className="NewChatScreen-users-list">
<NewGroupButton history={history} />
<Suspense fallback={null}>
<UsersList onUserPick={onUserPick} />
</Suspense>
</div>
</Style>
)
}

View File

@ -0,0 +1,43 @@
import Button from '@material-ui/core/Button'
import AddIcon from '@material-ui/icons/Add'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
import { User } from '../../graphql/types'
const Style = styled.div`
position: fixed;
right: 10px;
bottom: 10px;
button {
min-width: 50px;
width: 50px;
height: 50px;
border-radius: 999px;
background-color: var(--secondary-bg);
color: white;
}
`
interface CreateGroupButtonProps {
history: History
users: User.Fragment[]
// users: any[]
}
export default ({ history, users }: CreateGroupButtonProps) => {
const onClick = () => {
history.push('/new-chat/group/details', {
users,
})
}
return (
<Style className="CreateGroupButton">
<Button variant="contained" color="secondary" onClick={onClick}>
<AddIcon />
</Button>
</Style>
)
}

View File

@ -0,0 +1,39 @@
import Button from '@material-ui/core/Button'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
margin-left: -20px;
.NewGroupNavbar-title {
line-height: 56px;
}
.NewGroupNavbar-back-button {
color: var(--primary-text);
}
`
interface NewGroupNavbarProps {
history: History
}
export default ({ history }: NewGroupNavbarProps) => {
const navToChats = () => {
history.push('/new-chat')
}
return (
<Style className="NewGroupNavbar">
<Button className="NewGroupNavbar-back-button" onClick={navToChats}>
<ArrowBackIcon />
</Button>
<div className="NewGroupNavbar-title">New Chat Group</div>
</Style>
)
}

View File

@ -0,0 +1,32 @@
import * as React from 'react'
import { useState, Suspense } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import styled from 'styled-components'
import Navbar from '../Navbar'
import UsersList from '../UsersList'
import CreateGroupButton from './CreateGroupButton'
import NewGroupNavbar from './NewGroupNavbar'
const Style = styled.div`
.UsersList {
height: calc(100% - 56px);
overflow-y: overlay;
}
`
export default ({ history }: RouteComponentProps) => {
const [selectedUsers, setSelectedUsers] = useState([])
return (
<Style className="NewGroupScreen Screen">
<Navbar>
<NewGroupNavbar history={history} />
</Navbar>
<Suspense fallback={null}>
<UsersList selectable onSelectionChange={setSelectedUsers} />
</Suspense>
{!!selectedUsers.length && <CreateGroupButton history={history} users={selectedUsers} />}
</Style>
)
}

View File

@ -0,0 +1,130 @@
import TextField from '@material-ui/core/TextField'
import EditIcon from '@material-ui/icons/Edit'
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'
import gql from 'graphql-tag'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useMutation } from 'react-apollo-hooks'
import { RouteComponentProps } from 'react-router-dom'
import styled from 'styled-components'
import * as fragments from '../../graphql/fragments'
import { SettingsFormMutation, User } from '../../graphql/types'
import { useMe } from '../../services/auth.service'
import { pickPicture, uploadProfilePicture } from '../../services/picture.service'
import Navbar from '../Navbar'
import SettingsNavbar from './SettingsNavbar'
const Style = styled.div`
.SettingsForm-picture {
max-width: 300px;
display: block;
margin: auto;
margin-top: 50px;
img {
object-fit: cover;
border-radius: 50%;
margin-bottom: -34px;
width: 300px;
height: 300px;
}
svg {
float: right;
font-size: 30px;
opacity: 0.5;
border-left: black solid 1px;
padding-left: 5px;
cursor: pointer;
}
}
.SettingsForm-name-input {
display: block;
margin: auto;
width: calc(100% - 50px);
margin-top: 50px;
> div {
width: 100%;
}
}
`
const mutation = gql`
mutation SettingsFormMutation($name: String, $picture: String, $userId: Int) {
update_users(_set: {name: $name, picture: $picture}, where: {id: {_eq: $userId}}) {
affected_rows
returning {
id
name
picture
username
}
}
}
`
export default ({ history }: RouteComponentProps) => {
const me = useMe()
const [myName, setMyName] = useState(me.name)
const [myPicture, setMyPicture] = useState(me.picture)
const updateUser = useMutation<SettingsFormMutation.Mutation, SettingsFormMutation.Variables>(
mutation,
{
variables: { name: myName, picture: myPicture, userId: me.id },
update: (client, { data: { update_users } }) => {
me.picture = myPicture
me.name = myPicture
client.writeFragment<User.Fragment>({
id: defaultDataIdFromObject(me),
fragment: fragments.user,
data: me,
})
},
},
)
useEffect(
() => {
if (myPicture !== me.picture) {
updateUser()
}
},
[myPicture],
)
const updateName = ({ target }) => {
setMyName(target.value)
}
const updatePicture = async () => {
const file = await pickPicture()
if (!file) return
const { url } = await uploadProfilePicture(file)
setMyPicture(url)
}
return (
<Style className={name}>
<div className="SettingsForm-picture">
<img src={myPicture || '/assets/default-profile-pic.jpg'} />
<EditIcon onClick={updatePicture} />
</div>
<TextField
className="SettingsForm-name-input"
label="Name"
value={myName}
onChange={updateName}
onBlur={updateUser}
margin="normal"
placeholder="Enter your name"
/>
</Style>
)
}

View File

@ -0,0 +1,49 @@
import Button from '@material-ui/core/Button'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import { History } from 'history'
import * as React from 'react'
import styled from 'styled-components'
const Style = styled.div`
padding: 0;
display: flex;
flex-direction: row;
margin-left: -20px;
.SettingsNavbar-title {
line-height: 56px;
}
.SettingsNavbar-back-button {
color: var(--primary-text);
}
.SettingsNavbar-picture {
height: 40px;
width: 40px;
margin-top: 3px;
margin-left: -22px;
object-fit: cover;
padding: 5px;
border-radius: 50%;
}
`
interface SettingsNavbarProps {
history: History
}
export default ({ history }: SettingsNavbarProps) => {
const navToChats = () => {
history.push('/chats')
}
return (
<Style className={name}>
<Button className="SettingsNavbar-back-button" onClick={navToChats}>
<ArrowBackIcon />
</Button>
<div className="SettingsNavbar-title">Settings</div>
</Style>
)
}

View File

@ -0,0 +1,17 @@
import * as React from 'react'
import { Suspense } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import Navbar from '../Navbar'
import SettingsForm from './SettingsForm'
import SettingsNavbar from './SettingsNavbar'
export default ({ history }: RouteComponentProps) => (
<div className="SettingsScreen Screen">
<Navbar>
<SettingsNavbar history={history} />
</Navbar>
<Suspense fallback={null}>
<SettingsForm />
</Suspense>
</div>
)

View File

@ -0,0 +1,200 @@
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import CheckCircle from '@material-ui/icons/CheckCircle'
import gql from 'graphql-tag'
import * as React from 'react'
import { useState } from 'react'
import { useQuery } from 'react-apollo-hooks'
import { useSubscription } from '../polyfills/react-apollo-hooks'
import styled from 'styled-components'
import * as fragments from '../graphql/fragments'
import { ExistingChatUsers, RemainingUsers, RemainingUsersSub } from '../graphql/types'
import { useMe } from '../services/auth.service'
const Style = styled.div`
.UsersList-users-list {
padding: 0;
}
.UsersList-user-item {
position: relative;
padding: 7.5px 15px;
display: flex;
${props => props.selectable && 'cursor: pointer;'}
}
.UsersList-profile-pic {
height: 50px;
width: 50px;
object-fit: cover;
border-radius: 50%;
}
.UsersList-name {
padding-left: 15px;
font-weight: bold;
}
.UsersList-checkmark {
position: absolute;
left: 50px;
top: 35px;
color: var(--secondary-bg);
background-color: white;
border-radius: 50%;
}
`
const existingUsersQuery = gql`
query ExistingChatUsers($userId: Int){
chat(where:{users:{user_id:{_eq:$userId}}, owner_id:{_is_null:true}}){
id
name
owner_id
users(order_by:[{user_id:desc}],where:{user_id:{_neq:$userId}}) {
user_id
user {
...user
}
}
}
}
${fragments.user}
`;
const remainingUsersQuery = gql`
query RemainingUsers($existingUsersId: [Int]) {
users(order_by:[{id:desc}],where:{id:{_nin:$existingUsersId}}){
...user
}
}
${fragments.user}
`;
const remainingUsersSubscription = gql`
subscription RemainingUsersSub($existingUsersId: [Int]) {
users(order_by:[{id:desc}],where:{id:{_nin:$existingUsersId}}){
...user
}
}
${fragments.user}
`;
interface UsersListProps {
selectable?: boolean
onSelectionChange?: (users: any[]) => void
// onUserPick?: (user: User.Fragment) => void
onUserPick?: (user: any[]) => void
}
export default (props: UsersListProps) => {
const { selectable, onSelectionChange, onUserPick } = {
selectable: false,
onSelectionChange: () => {},
onUserPick: () => {},
...props,
}
const [selectedUsers, setSelectedUsers] = useState([])
const me = useMe()
const currentUserId = me.id;
const { data: { chat } } = useQuery<ExistingChatUsers.Query, ExistingChatUsers.Variables>(
existingUsersQuery,
{variables: {userId: me.id}, suspend: true}
);
let existingChatUsers
let existingUserIds
if(chat) {
existingChatUsers = chat.map((chat) => {
return({...chat.users[0].user, isExisting: true, chat_id: chat.id})
})
existingUserIds = chat.map((chat) => {
return(chat.users[0].user_id)
})
}
if(existingUserIds) {
existingUserIds.push(currentUserId);
}
useSubscription<RemainingUsersSub.Subscription, RemainingUsersSub.Variables>(
remainingUsersSubscription, { variables: {existingUsersId: existingUserIds},
// onSubscriptionData: ({ client, subscriptionData: { userUpdated } }) => {
onSubscriptionData: ({ client, subscriptionData: { users } }) => {
let queryUsers
try {
queryUsers = client.readQuery<RemainingUsers.Query, RemainingUsers.Variables>({
query: remainingUsersQuery,
variables: {existingUsersId: existingUserIds}
}).users
} catch (e) {
console.error(e);
}
if(queryUsers && users && users.length && !queryUsers.some(_user => _user.id === users[0].id)) {
const newUserList = queryUsers.unshift(users[0])
client.writeQuery<RemainingUsers.Query, RemainingUsers.Variables>({
query: remainingUsersQuery,
variables: {existingUsersId: existingUserIds},
data: { users: queryUsers },
})
}
}
})
const { data: { users } } = useQuery<RemainingUsers.Query, RemainingUsers.Variables>(
remainingUsersQuery,
{variables: {existingUsersId: existingUserIds}, suspend: true}
);
let remainingUsers;
if(users) {
remainingUsers = users.map((user) => {
return({...user, isExisting: false })
});
}
let finalUsers
if(existingChatUsers && remainingUsers) {
finalUsers = existingChatUsers.concat(remainingUsers)
}
const onListItemClick = user => {
if (!selectable) {
return onUserPick(user)
}
if (selectedUsers.includes(user)) {
const index = selectedUsers.indexOf(user)
selectedUsers.splice(index, 1)
} else {
selectedUsers.push(user)
}
setSelectedUsers(selectedUsers)
onSelectionChange(selectedUsers)
}
return (
<Style className="UsersList" selectable={selectable}>
<List className="UsersList-users-list">
{finalUsers.map(user => {
const isSelectedUser = selectedUsers.some((el) => {
return el.id === user.id
});
return(
<ListItem
className="UsersList-user-item"
key={user.id}
button
onClick={onListItemClick.bind(null, user)}
>
<img
className="UsersList-profile-pic"
src={user.picture || '/assets/default-profile-pic.jpg'}
/>
<div className="UsersList-name">{user.name}</div>
{isSelectedUser && <CheckCircle className="UsersList-checkmark" />}
</ListItem>
)})}
</List>
</Style>
)
}

View File

@ -0,0 +1,2 @@
introspection.json
types.ts

View File

@ -0,0 +1,16 @@
import gql from 'graphql-tag'
import message from './message.fragment'
export default gql `
fragment chat on chat {
id
name
picture
owner_id
created_at
messages(order_by:[{created_at: asc}]) {
...message
}
}
${message}
`

View File

@ -0,0 +1,4 @@
export { default as chat } from './chat.fragment'
export { default as message } from './message.fragment'
export { default as messageUser } from './messageUser.fragment'
export { default as user } from './user.fragment'

View File

@ -0,0 +1,14 @@
import gql from 'graphql-tag'
export default gql`
fragment message on message {
id
chat_id
sender {
id
name
}
content
created_at
}
`

View File

@ -0,0 +1,14 @@
import gql from 'graphql-tag'
export default gql`
fragment messageUser on message_user {
id
chat_id
sender {
id
name
}
content
created_at
}
`

View File

@ -0,0 +1,10 @@
import gql from 'graphql-tag'
export default gql`
fragment user on users {
id
username
name
picture
}
`

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
query Chats {
chat {
...chat
messages {
...message
}
}
}
${fragments.chat}
${fragments.message}
`

View File

@ -0,0 +1,3 @@
export { default as chats } from './chats.query'
export { default as users } from './users.query'
export { default as me } from './me.query'

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
query Me {
users {
...user
}
}
${fragments.user}
`

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
query Users {
users {
...user
}
}
${fragments.user}
`

View File

@ -0,0 +1,17 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
subscription ChatsListQuerySubUpdate($userId: Int!) {
chat(order_by:[{messages_aggregate:{max:{created_at:desc}}}]) {
...chat
users(where:{user_id:{_neq:$userId}}) {
user {
...user
}
}
}
}
${fragments.chat}
${fragments.user}
`

View File

@ -0,0 +1,3 @@
export { default as chatUpdated } from './chatUpdated.subscription'
export { default as messageAdded } from './messageAdded.subscription'
export { default as userUpdated } from './userUpdated.subscription'

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
subscription MessageAdded {
message_user {
...messageUser
}
}
${fragments.messageUser}
`

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
import * as fragments from '../fragments'
export default gql `
subscription UserUpdated {
users(order_by:[{id:desc}]) {
...user
}
}
${fragments.user}
`

View File

@ -0,0 +1,26 @@
:root {
--primary-bg: #2c6157;
--secondary-bg: #6fd056;
--primary-text: white;
--secondary-text: white;
}
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
padding: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
#root {
height: 100%;
}
.Screen {
position: relative;
height: 100%;
}

View File

@ -0,0 +1,34 @@
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import React from 'react';
import { Suspense } from 'react'
import ReactDOM from 'react-dom';
import { ApolloProvider } from 'react-apollo-hooks';
import './index.css';
import App from './App';
import apolloClient from './apollo-client'
import * as serviceWorker from './serviceWorker';
const theme = createMuiTheme({
palette: {
primary: { main: '#2c6157' },
secondary: { main: '#6fd056' },
},
typography: {
useNextVariants: true,
},
})
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<ApolloProvider client={apolloClient}>
<Suspense fallback={null}>
<App />
</Suspense>
</ApolloProvider>
</MuiThemeProvider>
, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,72 @@
import { DataProxy } from 'apollo-cache'
import { OperationVariables, FetchPolicy } from 'apollo-client'
import { DocumentNode, GraphQLError } from 'graphql'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useApolloClient } from 'react-apollo-hooks'
import * as isEqual from 'react-fast-compare'
export type SubscriptionOptions<T, TVariables> = {
variables?: TVariables
fetchPolicy?: FetchPolicy
onSubscriptionData?: (options?: { client?: DataProxy; subscriptionData?: T }) => any
}
export const useSubscription = <T, TVariables = OperationVariables>(
query: DocumentNode,
options: SubscriptionOptions<T, TVariables> = {},
): {
data: T | { [key: string]: void }
error?: GraphQLError
loading: boolean
} => {
const onSubscriptionData = options.onSubscriptionData
const prevOptions = useRef<typeof options | null>(null)
const client = useApolloClient()
const [data, setData] = useState<T | {}>({})
const [error, setError] = useState<GraphQLError | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const subscriptionOptions = {
query,
variables: options.variables,
fetchPolicy: options.fetchPolicy,
}
useEffect(
() => {
prevOptions.current = subscriptionOptions
const subscription = client
.subscribe<{ data: T }, TVariables>(subscriptionOptions)
.subscribe({
next: ({ data }) => {
setData(data)
if (onSubscriptionData) {
onSubscriptionData({ client, subscriptionData: data })
}
},
error: err => {
setError(err)
setLoading(false)
},
complete: () => {
setLoading(false)
},
})
return () => {
subscription.unsubscribe()
}
},
[isEqual(prevOptions.current, subscriptionOptions) ? prevOptions.current : subscriptionOptions],
)
return useMemo(
() => ({
data,
error,
loading,
}),
[data, error, loading],
)
}

View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,135 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read http://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit http://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

@ -0,0 +1,89 @@
import * as React from 'react'
import { useContext } from 'react'
import { useQuery } from 'react-apollo-hooks'
import { Redirect } from 'react-router-dom'
import store from '../apollo-client'
import * as queries from '../graphql/queries'
import { Me } from '../graphql/types'
import { useSubscriptions } from './cache.service'
const MyContext = React.createContext(null)
export const useMe = () => {
return useContext(MyContext)
}
export const withAuth = (Component: React.ComponentType) => {
return props => {
if (!getAuthHeader()) return <Redirect to="/sign-in" />
// Validating against server
const fetchUser = useQuery<Me.Query, Me.Variables>(queries.me, { suspend: true, context: { headers: {'x-hasura-role': 'mine'}} })
const myResult = fetchUser.data.users ? fetchUser.data.users[0] : {};
useSubscriptions(myResult)
return (
<MyContext.Provider value={myResult}>
<Component {...props} />
</MyContext.Provider>
)
}
}
export const storeAuthHeader = (auth: string) => {
localStorage.setItem('Authorization', 'Bearer '+auth)
}
export const getAuthHeader = (): string | null => {
return localStorage.getItem('Authorization') || null
}
export const signIn = ({ username, password }) => {
return fetch(`${process.env.REACT_APP_AUTH_URL}/login`, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: {
'Content-Type': 'application/json'
},
})
.then(res => {
if (res.status < 400) {
return res.json().then((data) => {
const token = data.token;
storeAuthHeader(token);
});
} else {
return Promise.reject(res.statusText)
}
})
}
export const signUp = ({ username, password, name }) => {
return fetch(`${process.env.REACT_APP_AUTH_URL}/signup`, {
method: 'POST',
body: JSON.stringify({ name, username, password, confirmPassword: password }),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
})
}
export const signOut = () => {
localStorage.removeItem('Authorization')
// window.location.href = '/sign-in'
return store.clearStore()
}
export default {
useMe,
withAuth,
storeAuthHeader,
getAuthHeader,
signIn,
signUp,
signOut,
}

View File

@ -0,0 +1,97 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'
import * as fragments from '../graphql/fragments'
import * as subscriptions from '../graphql/subscriptions'
import * as queries from '../graphql/queries'
import gql from 'graphql-tag'
import {
ChatsListQuerySubUpdate,
ChatsListQueryCache,
MessageAdded,
UserUpdated,
} from '../graphql/types'
import { useSubscription } from '../polyfills/react-apollo-hooks'
const query = gql`
query ChatsListQueryCache($userId: Int!) {
chat(order_by:[{messages_aggregate:{max:{created_at:desc}}}]) {
...chat
users(where:{user_id:{_neq:$userId}}) {
user {
...user
}
}
}
}
${fragments.chat}
${fragments.user}
`;
export const useSubscriptions = (userResult) => {
useSubscription<ChatsListQuerySubUpdate.Subscription, ChatsListQuerySubUpdate.Variables>(
subscriptions.chatUpdated, { variables: {userId: userResult.id},
onSubscriptionData: ({ client, subscriptionData: { chat } }) => {
let chats
if(chat && chat.length) {
try {
chats = client.readQuery<ChatsListQueryCache.Query, ChatsListQueryCache.Variables>({
query: query,
variables: {userId: userResult.id}
}).chat
} catch(e) {
console.error(e)
}
if (chats && !chats.some(_chat => _chat.id === chat[0].id)) {
chats.unshift(chat[0])
try {
client.writeQuery<ChatsListQueryCache.Query, ChatsListQueryCache.Variables>({
query: query,
variables: {userId: userResult.id},
data: { chat: chats },
})
} catch(e) {
console.error(e)
}
}
}
},
})
useSubscription<MessageAdded.Subscription>(subscriptions.messageAdded, {
onSubscriptionData: ({ client, subscriptionData: { message_user } }) => {
let chatId
let chats
if(message_user && message_user.length) {
chatId = message_user[0].chat_id
try {
chats = client.readQuery<ChatsListQueryCache.Query, ChatsListQueryCache.Variables>({
query: query,
variables: {userId: userResult.id}
}).chat
// find array index of new message's chat id in existing cache
const chatIndex = chats.findIndex(elem => elem.id === chatId)
const finalChats = [chats[chatIndex], ...chats.filter((item) => item.id !== chatId)]
client.writeQuery({
query: query,
variables: {userId: userResult.id},
data: { chat: finalChats },
})
} catch(e) {
console.error(e)
}
}
},
})
useSubscription<UserUpdated.Subscription>(subscriptions.userUpdated, {
onSubscriptionData: ({ client, subscriptionData: { users } }) => {
if(users && users[0]) {
client.writeFragment({
id: defaultDataIdFromObject(users[0]),
fragment: fragments.user,
data: users[0],
})
}
},
})
}

View File

@ -0,0 +1,31 @@
import { getAuthHeader } from './auth.service'
export const pickPicture = () => {
return new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = e => {
const target = e.target as HTMLInputElement
resolve(target.files[0])
}
input.onerror = reject
input.click()
})
}
export const uploadProfilePicture = file => {
const formData = new FormData()
formData.append('file', file)
formData.append('upload_preset', 'profile-pic')
return fetch(`${process.env.REACT_APP_AUTH_URL}/upload-profile-pic`, {
method: 'POST',
body: formData,
headers: {
Authorization: getAuthHeader(),
}
}).then(res => {
return res.json()
})
}

View File

@ -0,0 +1,35 @@
{
"compilerOptions": {
"outDir": "build/dist",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"downlevelIteration": true,
"resolveJsonModule": true,
"target": "es5",
"jsx": "preserve",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2017",
"dom",
"esnext.asynciterable"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"noImplicitAny": false,
"strict": false,
"module": "esnext"
},
"include": [
"src"
]
}

View File

@ -0,0 +1,29 @@
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"rules": {
"ordered-imports": false,
"object-literal-sort-keys": false,
"jsx-boolean-value": false,
"interface-name" : false,
"variable-name": false,
"no-string-literal": false,
"no-namespace": false,
"interface-over-type-literal": false,
"no-shadowed-variable": false,
"curly": false,
"no-label": false,
"no-empty": false,
"no-debugger": false,
"no-console": false,
"array-type": false
},
"linterOptions": {
"exclude": [
"config/**/*.js",
"node_modules/**/*.ts",
"coverage/lcov-report/*.js",
"*.json",
"**/*.json"
]
}
}

File diff suppressed because it is too large Load Diff