mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
2777b45335
commit
6223852e9d
@ -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
|
||||
```
|
@ -0,0 +1 @@
|
||||
node_modules
|
2
community/sample-apps/whatsapp-clone-typescript-react/auth-server/.gitignore
vendored
Normal file
2
community/sample-apps/whatsapp-clone-typescript-react/auth-server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
@ -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
|
@ -0,0 +1 @@
|
||||
web: npm start
|
@ -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 } }" }'
|
||||
```
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
}
|
@ -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 }
|
@ -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');
|
||||
};
|
@ -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 }
|
@ -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'
|
||||
}
|
||||
};
|
2665
community/sample-apps/whatsapp-clone-typescript-react/auth-server/package-lock.json
generated
Normal file
2665
community/sample-apps/whatsapp-clone-typescript-react/auth-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
endpoint: http://localhost:8080
|
@ -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
|
||||
--
|
||||
|
@ -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
|
@ -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
|
3
community/sample-apps/whatsapp-clone-typescript-react/react-app/.gitignore
vendored
Normal file
3
community/sample-apps/whatsapp-clone-typescript-react/react-app/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
@ -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.
|
@ -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
|
||||
|
@ -0,0 +1,6 @@
|
||||
require('ts-node').register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'commonjs'
|
||||
}
|
||||
})
|
@ -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
|
@ -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"]
|
||||
}
|
@ -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 |
@ -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>
|
@ -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"
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
":automergeMajor"
|
||||
],
|
||||
"baseBranches": [
|
||||
"master-step1",
|
||||
"master-step2",
|
||||
"master-step3",
|
||||
"master-step4"
|
||||
],
|
||||
"prHourlyLimit": 60,
|
||||
"recreateClosed": true
|
||||
}
|
32
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/App.css
Executable file
32
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/App.css
Executable 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
31
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/App.tsx
Executable file
31
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/App.tsx
Executable 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>
|
||||
)
|
@ -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,
|
||||
})
|
@ -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%;
|
||||
}
|
||||
`
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
2
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/graphql/.gitignore
vendored
Normal file
2
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/graphql/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
introspection.json
|
||||
types.ts
|
@ -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}
|
||||
`
|
@ -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'
|
@ -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
|
||||
}
|
||||
`
|
@ -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
|
||||
}
|
||||
`
|
@ -0,0 +1,10 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
fragment user on users {
|
||||
id
|
||||
username
|
||||
name
|
||||
picture
|
||||
}
|
||||
`
|
@ -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}
|
||||
`
|
@ -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'
|
@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag'
|
||||
import * as fragments from '../fragments'
|
||||
|
||||
export default gql `
|
||||
query Me {
|
||||
users {
|
||||
...user
|
||||
}
|
||||
}
|
||||
${fragments.user}
|
||||
`
|
@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag'
|
||||
import * as fragments from '../fragments'
|
||||
|
||||
export default gql `
|
||||
query Users {
|
||||
users {
|
||||
...user
|
||||
}
|
||||
}
|
||||
${fragments.user}
|
||||
`
|
@ -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}
|
||||
`
|
@ -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'
|
@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag'
|
||||
import * as fragments from '../fragments'
|
||||
|
||||
export default gql `
|
||||
subscription MessageAdded {
|
||||
message_user {
|
||||
...messageUser
|
||||
}
|
||||
}
|
||||
${fragments.messageUser}
|
||||
`
|
@ -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}
|
||||
`
|
@ -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%;
|
||||
}
|
@ -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();
|
@ -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 |
@ -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],
|
||||
)
|
||||
}
|
1
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/react-app-env.d.ts
vendored
Normal file
1
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
135
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/serviceWorker.js
vendored
Executable file
135
community/sample-apps/whatsapp-clone-typescript-react/react-app/src/serviceWorker.js
vendored
Executable 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();
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
@ -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],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
@ -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()
|
||||
})
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user