├── .expo ├── packager-info.json └── settings.json ├── .vscode └── settings.json ├── README.md ├── mobile ├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .watchmanconfig ├── App.js ├── app.json ├── assets │ └── icons │ │ ├── app-icon.png │ │ └── loading-icon.png ├── jsconfig.json ├── package.json ├── src │ ├── actions │ │ └── user.js │ ├── components │ │ ├── ButtonHeader.js │ │ ├── FeedCard │ │ │ ├── FeedCard.js │ │ │ ├── FeedCardBottom.js │ │ │ └── FeedCardHeader.js │ │ ├── HeaderAvatar.js │ │ ├── Loading.js │ │ ├── ProfileHeader.js │ │ ├── SignupForm.js │ │ └── Welcome.js │ ├── graphql │ │ ├── mutations │ │ │ ├── createTweet.js │ │ │ ├── favoriteTweet.js │ │ │ └── signup.js │ │ ├── queries │ │ │ ├── getTweets.js │ │ │ ├── getUserTweets.js │ │ │ └── me.js │ │ └── subscriptions │ │ │ ├── tweetAdded.js │ │ │ └── tweetFavorited.js │ ├── navigations.js │ ├── reducers │ │ ├── index.js │ │ ├── navigation.js │ │ └── user.js │ ├── screens │ │ ├── AuthenticationScreen.js │ │ ├── ExploreScreen.js │ │ ├── HomeScreen.js │ │ ├── NewTweetScreen.js │ │ ├── NotificationsScreen.js │ │ └── ProfileScreen.js │ ├── store.js │ └── utils │ │ └── constants.js └── yarn.lock └── server ├── .babelrc ├── .eslintrc ├── .gitignore ├── .vscode └── settings.json ├── package.json ├── src ├── config │ ├── constants.js │ ├── db.js │ ├── middlewares.js │ └── pubsub.js ├── graphql │ ├── resolvers │ │ ├── index.js │ │ ├── tweet-resolvers.js │ │ └── user-resolvers.js │ └── schema.js ├── index.js ├── mocks │ └── index.js ├── models │ ├── FavoriteTweet.js │ ├── FollowingUser.js │ ├── Tweet.js │ └── User.js └── services │ └── auth.js └── yarn.lock /.expo/packager-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "expoServerPort": null 3 | } -------------------------------------------------------------------------------- /.expo/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostType": "tunnel", 3 | "lanType": "ip", 4 | "dev": true, 5 | "strict": false, 6 | "minify": false, 7 | "urlType": "exp", 8 | "urlRandomness": null 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Clone with Graphql and React-Native 2 | 3 | - [Part 1](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-1) 4 | - [Part 2](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-2---tweet-basic-crud) 5 | - [Part 3](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-3---creation-of-the-user-with-signup-and-login-resolvers) 6 | - [Part 4](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-4---authentication-server-side-with-jwt) 7 | - [Part 5](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-5---add-tweet-belongs-to-user) 8 | - [Part 6](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-6---setup-of-mobile-side) 9 | - [Part 7](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-7---design-of-feed-card) 10 | - [Part 8](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative/tree/ep08#part-8---setup-the-navigations) 11 | - [Part 9](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-9---connect-the-mobile-app-with-the-server) 12 | - [Part 10](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-10---designing-the-signup-screen) 13 | - [Part 11](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-11---authorization-and-apollo-middleware) 14 | - [Part 12](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-12---me-query-with-header-avatar-and-logout) 15 | - [Part 13](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-13---designing-the-newtweetscreen) 16 | - [Part 14](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-14---createtweet-mutation-add-to-the-newscreentweet--optimistic-ui) 17 | - [Part 15](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-15---subscriptions-for-real-time-data---tweetcreation) 18 | - [Part 16-Prelude](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-16-prelude---talk-about-the-favorite-tweet-features) 19 | - [Part 16](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-16---code-the-favorite-tweet-features-server-side) 20 | - [Part 17](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-17---favorite-tweet-mutation-mobile-side) 21 | - [Part 18](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-18---adding-ui-change-for-the-favorite-tweet) 22 | - [Part 19](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-19---subscription-for-the-favorite-tweet) 23 | - [Part 20](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-20---placeholder-for-loading-card) 24 | - [Part 21](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-21---design-of-profile-page) 25 | - [Part 22](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-22---using-fragments) 26 | - [Part 23](https://github.com/EQuimper/twitter-clone-with-graphql-reactnative#part-23---creation-of-the-following-schema) 27 | 28 | ### What is this? 29 | 30 | This is a simple Youtube tutorial where we go over some new tech. The end product is to build a Twitter kind of clone where we have real time updates. 31 | 32 | [End Product Intro Video](https://youtu.be/U9OfNJ9Ia70) 33 | 34 | ### Prerequisites 35 | 36 | - Installation of MongoDB, React Native and Nodejs 37 | - Understanding of MongoDB, Nodejs, React Native and a bit of GraphQL 38 | - Know what websocket are 39 | - Be patient with me ;) 40 | 41 | ## Part 1 42 | 43 | ### Video 44 | 45 | - [The Setup](https://youtu.be/33qP1QMmjv8) 46 | - [Part 1](https://youtu.be/qLBwByURMf8) 47 | 48 | This part is mainly for the basic setup of the server. We go over the installation of Express and the basic GraphQL setup. We also add MongoDB and we make mocks on it. 49 | 50 | 1. Create a folder to put the server and mobile folder in 51 | 2. Cd into the server folder and run the command `yarn init` and click enter on each question 52 | 3. Run `yarn add express cross-env body-parser` 53 | 4. Create a folder called `src` and create a file inside called `index.js` 54 | 5. Put the basic setup of express at the top of the file 55 | 56 | ```js 57 | import express from 'express'; 58 | import bodyParser from 'body-parser'; 59 | 60 | const app = express(); // create an instance of express 61 | 62 | const PORT = process.env.PORT || 3000; // create the port 63 | 64 | app.use(bodyParser.json()); // add body-parser as the json parser middleware 65 | 66 | app.listen(PORT, err => { 67 | if (err) { 68 | console.error(err); 69 | } else { 70 | console.log(`App listen on port: ${PORT}`); 71 | } 72 | }); 73 | ``` 74 | 5. Now we need to install babel in the devDependencies because we want to write in latest javascript feature. 75 | 76 | `yarn add -D babel-cli babel-plugin-transform-object-rest-spread babel-preset-env` 77 | 78 | 6. Go into your `package.json` and add this script under the scripts: 79 | 80 | ```json 81 | "dev": "cross-env NODE_ENV=dev nodemon --exec babel-node src/index.js" 82 | ``` 83 | 84 | 7. Create a `.babelrc` with these settings: 85 | 86 | ``` 87 | { 88 | "presets": [ 89 | [ 90 | "env", 91 | { 92 | "targets": { 93 | "node": "6.10" 94 | } 95 | } 96 | ] 97 | ], 98 | "plugins": [ 99 | [ 100 | "transform-object-rest-spread", 101 | { 102 | "useBuiltIns": true 103 | } 104 | ] 105 | ] 106 | } 107 | ``` 108 | 109 | Here I make use of `babel-preset-env`. This helps us to setup babel without pain. 110 | 111 | 8. Now it is time to set up some GraphQL stuff. First create a folder called `graphql` inside `src`. After that create a `schema.js` file and put the follow in it: 112 | 113 | ```js 114 | export default` 115 | type Tweet { 116 | _id: String 117 | text: String 118 | } 119 | 120 | type Query { 121 | getTweets: [Tweet] 122 | } 123 | 124 | schema { 125 | query: Query 126 | } 127 | `; 128 | ``` 129 | 130 | Here we have a basic schema for the tweet. Also we have a query call `getTweets` you render a list of this tweet. 131 | 132 | 9. Now it's time to write the resolver for the `getTweets`. Create a folder `resolvers` inside `src/graphql/`. After that, create a file called `tweet-resolver.js`. Inside this one put this: 133 | 134 | ```js 135 | import Tweet from '../../models/Tweet'; 136 | 137 | export default { 138 | getTweets: () => Tweet.find({}) 139 | } 140 | ``` 141 | 142 | And create a `index.js` file inside `src/graphql/resolvers/` folder and put : 143 | 144 | ```js 145 | import TweetResolvers from './tweet-resolver'; 146 | 147 | export default { 148 | Query: { 149 | getTweets: TweetResolvers.getTweets 150 | } 151 | } 152 | ``` 153 | 154 | But where did this `Tweet` models come from? Yes, this is time to setup the db. 155 | 156 | 10. Create a `db.js` inside the `src/config/` folder. Put this lines in after running this command. 157 | 158 | `yarn add mongoose` 159 | 160 | ```js 161 | /* eslint-disable no-console */ 162 | 163 | import mongoose from 'mongoose'; 164 | 165 | import constants from './constants'; 166 | 167 | mongoose.Promise = global.Promise; 168 | 169 | mongoose.set('debug', true); // debug mode on 170 | 171 | try { 172 | mongoose.connect(constants.DB_URL, { 173 | useMongoClient: true, 174 | }); 175 | } catch (err) { 176 | mongoose.createConnection(constants.DB_URL, { 177 | useMongoClient: true, 178 | }); 179 | } 180 | 181 | mongoose.connection 182 | .once('open', () => console.log('MongoDB Running')) 183 | .on('error', e => { 184 | throw e; 185 | }); 186 | ``` 187 | 188 | Nothing crazy here just the basic setup of a db with mongodb. But where do the constants file came ? Time to create it. So inside the `src/config/` folder create a file called `constants.js'` and put these lines in. 189 | 190 | ```js 191 | export default { 192 | PORT: process.env.PORT || 3000, 193 | DB_URL: 'mongodb://localhost/tweeter-development', 194 | GRAPHQL_PATH: '/graphql' 195 | } 196 | ``` 197 | 198 | Here, these are the constants where we put the base configuration of the server. 199 | 200 | - PORT -> the port of the app 201 | - DB_URL -> url for the db 202 | - GRAPHQL_PATH -> the url for graphql server 203 | 204 | 11. Time to create the Mongodb schema of the tweet. Inside `src/` folder create `models` folder and inside this one `Tweet.js` file. Inside this one we create a basic schema for now ;) 205 | 206 | ```js 207 | import mongoose, { Schema } from 'mongoose'; 208 | 209 | const TweetSchema = new Schema({ 210 | text: String, 211 | }); 212 | 213 | export default mongoose.model('Tweet', TweetSchema); 214 | ``` 215 | 216 | 12. Time to update the simple express server for add graphql on it inside `src/index.js` 217 | 218 | `yarn add apollo-server-express graphql-tools` 219 | 220 | ```js 221 | import express from 'express'; 222 | import { graphqlExpress, graphiqlExpress } from 'apollo-server-express'; 223 | import { makeExecutableSchema } from 'graphql-tools'; 224 | import { createServer } from 'http'; 225 | import bodyParser from 'body-parser'; 226 | 227 | import './config/db'; 228 | import constants from './config/constants'; 229 | import typeDefs from './graphql/schema'; 230 | import resolvers from './graphql/resolvers'; 231 | 232 | app.use(bodyParser.json()); 233 | 234 | app.use( 235 | '/graphiql', 236 | graphiqlExpress({ 237 | endpointURL: constants.GRAPHQL_PATH, 238 | }), 239 | ); 240 | 241 | app.use( 242 | constants.GRAPHQL_PATH, 243 | graphqlExpress({ 244 | schema 245 | }), 246 | ); 247 | 248 | graphQLServer.listen(constants.PORT, err => { 249 | if (err) { 250 | console.error(err); 251 | } else { 252 | console.log(`App running on port: ${constants.PORT}`); 253 | } 254 | }); 255 | ``` 256 | 257 | Nothing crazy here, basic setup + add graphiql the IDE of grahpql. 258 | 259 | 13. Time to mocks some stuff ? Sure ;) That's gonna be easy, first think add `yarn add -D faker` which is a library to help you with mock data. We will create 10 fake mock tweet. 260 | 261 | Inside `src` create a folder `mocks` and a file `index.js` inside this one. 262 | 263 | ```js 264 | import faker from 'faker'; 265 | 266 | import Tweet from '../models/Tweet'; 267 | 268 | const TWEETS_TOTAL = 10; 269 | 270 | export default async () => { 271 | try { 272 | await Tweet.remove(); 273 | 274 | await Array.from({ length: TWEETS_TOTAL }).forEach(async () => { 275 | await Tweet.create({ 276 | text: faker.lorem.paragraphs(1), 277 | }) 278 | }); 279 | } catch (error) { 280 | throw error; 281 | } 282 | } 283 | ``` 284 | 285 | 14. Add this mocks promise to the server inside `src/index.js` 286 | 287 | ```js 288 | // all other import 289 | import mocks from './mocks'; 290 | 291 | // other code here 292 | 293 | mocks().then(() => { 294 | graphQLServer.listen(constants.PORT, err => { 295 | if (err) { 296 | console.error(err); 297 | } else { 298 | console.log(`App running on port: ${constants.PORT}`); 299 | } 300 | }); 301 | }); 302 | ``` 303 | 304 | 15. Time to test it. Go on https://github.com/skevy/graphiql-app and download the app. Why ? Because we will need this jwt auth later ;) 305 | 306 | 16. Open this tool now and add this in the left part. This is a simple query where we get all 10 tweets. Don't forget to put the url `http://localhost:3000/graphql` 307 | 308 | ```js 309 | { 310 | getTweets { 311 | _id 312 | text 313 | } 314 | } 315 | ``` 316 | 317 | 17. If all work you should see that 318 | 319 | ![](https://image.ibb.co/huhn5Q/Screen_Shot_2017_07_19_at_7_10_45_PM.png) 320 | 321 | Good Job! 322 | 323 | --- 324 | 325 | ## Part 2 - Tweet Basic Crud 326 | 327 | ### Video 328 | 329 | - [Video here](https://youtu.be/tYiGpJGJatE) 330 | 331 | This part is going to be about creating the resolver for creating and get a single Tweet 332 | 333 | 1. Go inside `src/graphql/schema.js` and add this to the query object. 334 | 335 | ```js 336 | getTweet(_id: ID!): Tweet 337 | ``` 338 | 339 | As you can see here we create a resolver called getTweet where we pass in the _id **Required** for fetching the tweet. Pretty simple here. The `!` mean this is required. The ID is a type build in Graphql. This is for a unique identifier, which is the key for cache. 340 | 341 | 2. After we can go inside `src/graphql/resolvers/tweet-resolvers.js` and add this one. 342 | 343 | ```js 344 | getTweet: (_, { _id }) => Tweet.findById(_id), 345 | ``` 346 | 347 | Here we just find one single item by providing the id. Mongoose have an awesome method called `findById()` to make your life easier ;). Inside the resolver we have a function which takes 3 arguments. First one is the parent : "We gonna talk about it later", second one is the args object inside the argument inside the `schema.js` file. The third one is the context and we will also talk about it later. Here you see me destructuring the `{ _id }`, `_id` prop from the args. This is just to make my code a bit cleaner. 348 | 349 | 3. Now we need to go inside `src/graphql/resolvers/index.js` and add what we just did in the Query object 350 | 351 | ```js 352 | getTweet: TweetResolvers.getTweet, 353 | ``` 354 | 355 | Try it inside Graphiql, take a _id of one of the object by redoing the `getTweets` query and do this one 356 | 357 | ![](https://image.ibb.co/cuhHqQ/Screen_Shot_2017_07_20_at_5_26_05_PM.png) 358 | 359 | 4. Now time to do the mutataion one. For now I'm gonna break the code in 3 example and you can just put them in the file associate at the top of it. 360 | 361 | ##### src/grahpql/schema.js` 362 | 363 | ```js 364 | type Mutation { 365 | createTweet(text: String!): Tweet 366 | } 367 | 368 | schema { 369 | query: Query 370 | mutation: Mutation 371 | } 372 | ``` 373 | 374 | Here simple creation which requires a text to make it work. We also add this resolver inside a new type call Mutation. Mutation is all about everything else than GET, like PUT, DELETE, POST. Also we need to add this Mutation inside the schema object. 375 | 376 | ##### src/graphql/resolvers/tweet-resolvers.js 377 | 378 | ```js 379 | createTweet: (_, args) => Tweet.create(args), 380 | ``` 381 | 382 | Here we pass the full args object "Grahpql filter for us". For the validation that's gonna be later. 383 | 384 | ##### src/grahpql/resolvers/index.js 385 | 386 | ```js 387 | Mutation: { 388 | createTweet: TweetResolvers.createTweet, 389 | } 390 | ``` 391 | 392 | We pass the resolver createTweet inside the new object Mutation. 393 | 394 | Try it inside you graphiql ide. 395 | 396 | ![](https://image.ibb.co/jzUac5/Screen_Shot_2017_07_20_at_5_24_55_PM.png) 397 | 398 | 5. Perfect so now we can create a tweet. Time to update it ;) 399 | 400 | ##### src/grahpql/schema.js 401 | 402 | ```js 403 | updateTweet(_id: ID!, text: String): Tweet 404 | ``` 405 | 406 | Add this one inside the Mutation type. 407 | 408 | ##### src/graphql/resolvers/tweet-resolvers.js 409 | 410 | ```js 411 | updateTweet: (_, { _id, ...rest }) => Tweet.findByIdAndUpdate(_id, rest, { new: true }), 412 | ``` 413 | 414 | O boy, what we just did here ? If you don't understand the `...rest` I have made a video where I explain this thing ;) [Link Here](https://youtu.be/UA6fvCcSluA). 415 | 416 | We find the tweet by using the id and we make use of the `findByIdAndUpdate()` method of mongoose. First argument is the id of the object. The second is the object we change. The `{ new: true }` mean we want to send back the new object. 417 | 418 | ##### src/grahpql/resolvers/index.js 419 | 420 | ```js 421 | updateTweet: TweetResolvers.updateTweet, 422 | ``` 423 | 424 | 6. Now time to delete our awesome tweet ;) 425 | 426 | ##### src/grahpql/schema.js 427 | 428 | ```js 429 | deleteTweet(_id: ID!): Status 430 | ``` 431 | 432 | Where this Status type came ? Time to create it :) 433 | 434 | ```js 435 | type Status { 436 | message: String! 437 | } 438 | ``` 439 | 440 | This is my way for show in the front-end the message of something happening. Because remember when you delete something that does not exist anymore. 441 | 442 | ##### src/graphql/resolvers/tweet-resolvers.js 443 | 444 | ```js 445 | deleteTweet: async (_, { _id }) => { 446 | try { 447 | await Tweet.findByIdAndRemove(_id); 448 | return { 449 | message: 'Delete Success!' 450 | } 451 | } catch (error) { 452 | throw error; 453 | } 454 | } 455 | ``` 456 | 457 | Here this is the bigger resolver we create for this part. First this function gonna be an `async` one who mean this is gonna be asynchronous. First we delete the tweet and after we send the success message. 458 | 459 | ##### src/grahpql/resolvers/index.js 460 | 461 | ```js 462 | deleteTweet: TweetResolvers.deleteTweet 463 | ``` 464 | 465 | Put this inside Mutation. 466 | 467 | Time to test it. 468 | 469 | ![](https://image.ibb.co/btPGH5/Screen_Shot_2017_07_20_at_6_17_25_PM.png) 470 | 471 | 7. But now the problem is the tweet came in descendant fashion. We still don't have a way to managed it like in twitter the last tweet is at the top of the feed not the other way around. We can change that, its easy. Go inside the tweets models 472 | 473 | ##### src/models/Tweet.js 474 | 475 | ```js 476 | const TweetSchema = new Schema({ 477 | text: String, 478 | }, { timestamps: true }); 479 | ``` 480 | 481 | Here I add the timestamps who give us 2 new field to our model. `createdAt` and `updatedAt` who are dates. Cause of it we gonna add in the schema this 2 field. Also we gonna create a `scalar Date` which is the way to do a custom type in graphql. 482 | 483 | ##### src/graphql/schema.js 484 | 485 | ```js 486 | scalar Date 487 | 488 | type Tweet { 489 | _id: ID! 490 | text: String! 491 | createdAt: Date! 492 | updatedAt: Date! 493 | } 494 | ``` 495 | 496 | Now go inside the main resolver file after install `yarn add graphql-date` 497 | 498 | ##### src/grahpql/resolvers/index.js 499 | 500 | ```js 501 | import GraphQLDate from 'graphql-date'; 502 | 503 | import TweetResolvers from './tweet-resolvers'; 504 | 505 | export default { 506 | Date: GraphQLDate, 507 | Query: { 508 | getTweet: TweetResolvers.getTweet, 509 | getTweets: TweetResolvers.getTweets, 510 | }, 511 | Mutation: { 512 | createTweet: TweetResolvers.createTweet, 513 | updateTweet: TweetResolvers.updateTweet, 514 | deleteTweet: TweetResolvers.deleteTweet 515 | } 516 | }; 517 | ``` 518 | 519 | Graphql gonna always use this Date here when he see the type. Why we do this ? This is for serializing the input. 520 | 521 | Now time to add the sort on mongoose for get the lastone create first 522 | 523 | ##### src/grahpql/resolvers/tweet-resolvers.js 524 | 525 | ```js 526 | import Tweet from '../../models/Tweet'; 527 | 528 | export default { 529 | createTweet: (_, args) => Tweet.create(args), 530 | getTweet: (_, { _id }) => Tweet.findById(_id), 531 | getTweets: () => Tweet.find({}).sort({ createdAt: -1 }), 532 | updateTweet: (_, { _id, ...rest }) => Tweet.findByIdAndUpdate(_id, rest, { new: true }), 533 | deleteTweet: async (_, { _id }) => { 534 | try { 535 | await Tweet.findByIdAndRemove(_id); 536 | return { 537 | message: 'Delete Success!' 538 | } 539 | } catch (error) { 540 | throw error; 541 | } 542 | } 543 | }; 544 | ``` 545 | 546 | --- 547 | 548 | ## Part 3 - Creation of the user with signup and login resolvers 549 | 550 | ### Video 551 | 552 | [Link](https://youtu.be/3BeFarO6Wt8) 553 | 554 | In this part we go over the basic signup and login features for our app. We gonna start by creating the user schema for mongodb, after we gonna jump on the schema model inside grahpql. And finally we gonna work with the resolver and add action for crypt the user password. 555 | 556 | 1. First we need to make the basic setup for the user schema for mongodb. Create a new file inside `src/models/` call `User.js`. 557 | 558 | ```js 559 | import mongoose, { Schema } from 'mongoose'; 560 | 561 | const UserSchema = new Schema({ 562 | username: { 563 | type: String, 564 | unique: true, 565 | }, 566 | firstName: String, 567 | lastName: String, 568 | avatar: String, 569 | password: String, 570 | email: String, 571 | }, { timestamps: true }); 572 | 573 | export default mongoose.model('User', UserSchema); 574 | ``` 575 | 576 | Here we just setup the basic field of what our user gonna need. 577 | 578 | 2. Now time to create the user schema inside the defs of graphql. 579 | 580 | ##### src/graphql/schema.js 581 | 582 | ```js 583 | type User { 584 | _id: ID! 585 | username: String 586 | email: String! 587 | firstName: String 588 | lastName: String 589 | avatar: String 590 | createdAt: Date! 591 | updatedAt: Date! 592 | } 593 | ``` 594 | 595 | Ok what happen here ? Why do we don't have password ? We don't put the password for one unique and good reason. We don't want the client to have access to the password. In rest we need to manage a good way inside the models or maybe the controler for don't let a client access it. But in grahpql this is much more easy ;) 596 | 597 | 3. Perfect time to work on the signup resolver. inside `src/graphql/resolvers/` create a file call `user-resolvers.js` 598 | 599 | ```js 600 | import User from '../../models/User'; 601 | 602 | export default { 603 | signup: (_, { fullName, username, password, email, avatar }) => { 604 | const [firstName, ...lastName] = fullName.split(' '); 605 | return User.create({ firstName, lastName, username, password, email, avatar }); 606 | }, 607 | } 608 | ``` 609 | 610 | ??? What did I just did with the `fullName.split(' ')` what is this dark margic ;) "I'm just joking here ;)". 611 | 612 | Here as you can see first we make use of destructuring for get all the single value. I don't want to need to make use of args.email everywhere. After the thing is I want my client to have only one single input for get the fullName of the user. Less input and easier to work with. So the fullName came as a string with surely at least 2 words. the split method break the string inside an array when he see space. That's why the `(' ')`. After we say the first index = firstName and all the others equals the lastName. After we push it inside the new user creation. 613 | 614 | Now we need to add this inside the resolver index. 615 | 616 | ##### src/graphql/resolvers/index.js 617 | 618 | ```js 619 | import GraphQLDate from 'graphql-date'; 620 | 621 | import TweetResolvers from './tweet-resolvers'; 622 | import UserResolvers from './user-resolvers'; 623 | 624 | export default { 625 | Date: GraphQLDate, 626 | Query: { 627 | getTweet: TweetResolvers.getTweet, 628 | getTweets: TweetResolvers.getTweets, 629 | }, 630 | Mutation: { 631 | createTweet: TweetResolvers.createTweet, 632 | updateTweet: TweetResolvers.updateTweet, 633 | deleteTweet: TweetResolvers.deleteTweet, 634 | signup: UserResolvers.signup, 635 | } 636 | }; 637 | ``` 638 | 639 | And finally inside the schema graphql file 640 | 641 | ##### src/graphql/schema.js 642 | 643 | ```js 644 | signup(email: String!, fullName: String!, password: String!, avatar: String, username: String): User 645 | ``` 646 | 647 | For now we return the User type. 648 | 649 | Try it ;) 650 | 651 | ![](https://image.ibb.co/cD3Ns5/Screen_Shot_2017_07_23_at_11_57_09_AM.png) 652 | 653 | 4. We have a little problem at this point. If you check in your db using `Robo 3T` app or with the terminal you gonna see we save the password as a plain text string. This is REALLLLLLYYYYY bad. We need to crypt this thing and thank to the communities we have a simple packages who can make it for us easy. 654 | 655 | `yarn add bcrypt-nodejs` 656 | 657 | ##### src/models/User.js 658 | 659 | ```js 660 | import mongoose, { Schema } from 'mongoose'; 661 | import { hashSync } from 'bcrypt-nodejs'; 662 | 663 | const UserSchema = new Schema({ 664 | username: { 665 | type: String, 666 | unique: true, 667 | }, 668 | firstName: String, 669 | lastName: String, 670 | avatar: String, 671 | password: String, 672 | email: String, 673 | }, { timestamps: true }); 674 | 675 | UserSchema.pre('save', function(next) { 676 | if (this.isModified('password')) { 677 | this.password = this._hashPassword(this.password); 678 | return next(); 679 | } 680 | return next(); 681 | }); 682 | 683 | UserSchema.methods = { 684 | _hashPassword(password) { 685 | return hashSync(password); 686 | }, 687 | } 688 | 689 | export default mongoose.model('User', UserSchema); 690 | ``` 691 | 692 | Here we have import the function `hashSync` who hash the password for you. We make use of the pre hook method built in mongodb where before the user save we check first if he really modified the password. Because maybe we want to update the username of the user and we don't want to hash again the password for nothing. After we call a methods function call `_hashPassword()` who take the current password and return the hashing version. A methods is a function who can be use by the instance of the schema. Also `hashSync` is asyncronous. 693 | 694 | So now if you try again you should see a version hash of the password inside mongodb. 695 | 696 | ![](https://image.ibb.co/gyb625/Screen_Shot_2017_07_23_at_12_05_40_PM.png)\ 697 | 698 | 5. Time to loged this user. First because the user schema file is open we can create a function who decrypt the password and make sure this is the equivalent of what the use sent. 699 | 700 | ##### src/models/User.js 701 | 702 | ```js 703 | import mongoose, { Schema } from 'mongoose'; 704 | import { hashSync, compareSync } from 'bcrypt-nodejs'; 705 | 706 | const UserSchema = new Schema({ 707 | username: { 708 | type: String, 709 | unique: true, 710 | }, 711 | firstName: String, 712 | lastName: String, 713 | avatar: String, 714 | password: String, 715 | email: String, 716 | }, { timestamps: true }); 717 | 718 | UserSchema.pre('save', function(next) { 719 | if (this.isModified('password')) { 720 | this.password = this._hashPassword(this.password); 721 | return next(); 722 | } 723 | return next(); 724 | }); 725 | 726 | UserSchema.methods = { 727 | _hashPassword(password) { 728 | return hashSync(password); 729 | }, 730 | authenticateUser(password) { 731 | return compareSync(password, this.password); 732 | }, 733 | }; 734 | 735 | export default mongoose.model('User', UserSchema); 736 | ``` 737 | 738 | Here we add the `compareSync` import at the top. And finally we just create a methods on the user call `authenticateUser` who take the password coming from the client and return a boolean if the password match the one crypt. 739 | 740 | Time to jump on the resolver 741 | 742 | ##### src/graphql/resolvers/user-resolvers.js 743 | 744 | ```js 745 | import User from '../../models/User'; 746 | 747 | export default { 748 | signup: (_, { fullName, username, password, email, avatar }) => { 749 | const [firstName, ...lastName] = fullName.split(' '); 750 | return User.create({ firstName, lastName, username, password, email, avatar }); 751 | }, 752 | login: async (_, { email, password }) => { 753 | const user = await User.findOne({ email }); 754 | 755 | if (!user) { 756 | throw new Error('User not exist!'); 757 | } 758 | 759 | if (!user.authenticateUser(password)) { 760 | throw new Error('Password not match!'); 761 | } 762 | 763 | return user; 764 | } 765 | } 766 | ``` 767 | 768 | Here lot of code happening in the login but nothing crazy. First this gonna be an async function. After we destructuring the email and password field. 769 | 770 | First we find the user by his email. If no user came back we return an error about user not exist. 771 | 772 | After that mean a user exist we check if it match the password crypting version. If no we return an error about password don't match. 773 | 774 | Finally we return the user if all the guard case is good. 775 | 776 | ##### src/graphql/resolvers/index.js 777 | 778 | ```js 779 | Mutation: { 780 | createTweet: TweetResolvers.createTweet, 781 | updateTweet: TweetResolvers.updateTweet, 782 | deleteTweet: TweetResolvers.deleteTweet, 783 | signup: UserResolvers.signup, 784 | login: UserResolvers.login 785 | } 786 | ``` 787 | 788 | Add the login inside the mutation. 789 | 790 | ##### src/graphql/schema.js 791 | 792 | ```js 793 | type Mutation { 794 | createTweet(text: String!): Tweet 795 | updateTweet(_id: ID!, text: String): Tweet 796 | deleteTweet(_id: ID!): Status 797 | signup(email: String!, fullName: String!, password: String!, avatar: String, username: String): User 798 | login(email: String!, password: String!): User 799 | } 800 | ``` 801 | 802 | Add this in the schema grahpql. Here for now we return the user. 803 | 804 | Try it ;) 805 | 806 | ![](https://image.ibb.co/dLpyFQ/Screen_Shot_2017_07_23_at_12_12_51_PM.png) 807 | 808 | --- 809 | 810 | ## Part 4 - Authentication server side with JWT 811 | 812 | ### Video 813 | 814 | [Link](https://youtu.be/2mXUcBHoJcA) 815 | 816 | In this part we go throught the user authentication server side. We gonna make use of JWT "json web token" [JWT Website](https://jwt.io) for being the strategy we use. Why ? Because that keep the server serverless, and this is really easy to implement in mobile app and also web app. 817 | 818 | First thing we gonna need a new package for the app. 819 | 820 | `yarn add jsonwebtoken` 821 | 822 | This is a library who gonna help you create a jwt and also verify this last one. 823 | 824 | 1. We need to create a JWT_SECRET constants for the app. This way we make sure the JWT is provide by our server. 825 | 826 | ##### src/config/constants.js 827 | 828 | ```js 829 | export default { 830 | PORT: process.env.PORT || 3000, 831 | DB_URL: 'mongodb://localhost/tweet-development', 832 | GRAPHQL_PATH: '/graphql', 833 | JWT_SECRET: 'thisisasecret123' 834 | }; 835 | ``` 836 | 837 | For now we put a really bad secret. We gonna change that on production. 838 | 839 | 2. We gonna add a methods on the user for creating the jwt by taken his information. 840 | 841 | ##### src/models/User.js 842 | 843 | ```js 844 | import mongoose, { Schema } from 'mongoose'; 845 | import { hashSync, compareSync } from 'bcrypt-nodejs'; 846 | import jwt from 'jsonwebtoken'; 847 | 848 | import constants from '../config/constants'; 849 | 850 | const UserSchema = new Schema({ 851 | username: { 852 | type: String, 853 | unique: true 854 | }, 855 | firstName: String, 856 | lastName: String, 857 | avatar: String, 858 | password: String, 859 | email: String 860 | }, { timestamps: true }); 861 | 862 | UserSchema.pre('save', function(next) { 863 | if (this.isModified('password')) { 864 | this.password = this._hashPassword(this.password); 865 | return next(); 866 | } 867 | 868 | return next(); 869 | }); 870 | 871 | UserSchema.methods = { 872 | _hashPassword(password) { 873 | return hashSync(password); 874 | }, 875 | authenticateUser(password) { 876 | return compareSync(password, this.password); 877 | }, 878 | createToken() { 879 | return jwt.sign( 880 | { 881 | _id: this._id, 882 | }, 883 | constants.JWT_SECRET, 884 | ); 885 | }, 886 | } 887 | 888 | export default mongoose.model('User', UserSchema); 889 | ``` 890 | 891 | Here we add the method `createToken()` who finally return a JWT with the payload of the user id in. As you can see we need the secret for the creation. 892 | 893 | P.S we don't make it expires for this tutorial. 894 | 895 | 3. Now we gonna create the services for authentication for the server. I like to keep this kind of strategies outside of my current code. So if I decide to change stuff I know where to go. 896 | 897 | ##### src/services/auth.js 898 | 899 | ```js 900 | import jwt from 'jsonwebtoken'; 901 | 902 | import User from '../models/User'; 903 | import constants from '../config/constants'; 904 | 905 | export async function requireAuth(user) { 906 | if (!user || !user._id) { 907 | throw new Error('Unauthorized'); 908 | } 909 | 910 | const me = await User.findById(user._id); 911 | 912 | if (!me) { 913 | throw new Error('Unauthorized'); 914 | } 915 | 916 | return me; 917 | } 918 | 919 | export function decodeToken(token) { 920 | const arr = token.split(' '); 921 | 922 | if (arr[0] === 'Bearer') { 923 | return jwt.verify(arr[1], constants.JWT_SECRET); 924 | } 925 | 926 | throw new Error('Token not valid!'); 927 | } 928 | ``` 929 | 930 | Here I create a function call `requireAuth` who take a user object. This is coming from the client side inside the context "We gonna talk about this later". 931 | 932 | First I make sure we have user is not null and also _id prop too. 933 | 934 | After I search for the user with the user._id. Just again for security and maybe too if my user delete is account and try to do other stuff we make sure if the jwt pass the validation we can push out the user from our app. 935 | 936 | We return it for maybe take it inside resolver later. 937 | 938 | The second function is `decodeToken` who take a token string. First thing we split it because I want the jwt too look like `Bearer oiwgnwioeungowingonwogn`. But the `jwt.verify` only need the token himself. 939 | 940 | 3. But now how can we access the user for make the requireAuth function work ? 941 | 942 | First we gonna refact the code. 943 | 944 | We gonna break up all the middlewares inside the same file and we gonna import it inside the index.js file 945 | 946 | ##### src/config/middlewares.js 947 | 948 | ```js 949 | import bodyParser from 'body-parser'; 950 | import { graphiqlExpress, graphqlExpress } from 'apollo-server-express'; 951 | import { makeExecutableSchema } from 'graphql-tools'; 952 | 953 | import constants from './constants'; 954 | import typeDefs from '../graphql/schema'; 955 | import resolvers from '../graphql/resolvers'; 956 | 957 | const schema = makeExecutableSchema({ 958 | typeDefs, 959 | resolvers, 960 | }); 961 | 962 | export default app => { 963 | app.use(bodyParser.json()); 964 | app.use( 965 | '/graphiql', 966 | graphiqlExpress({ 967 | endpointURL: constants.GRAPHQL_PATH, 968 | }), 969 | ); 970 | app.use( 971 | constants.GRAPHQL_PATH, 972 | graphqlExpress({ 973 | schema, 974 | context: { 975 | user: req.user, 976 | }, 977 | }), 978 | ); 979 | }; 980 | ``` 981 | 982 | ##### src/index.js 983 | 984 | ```js 985 | /* eslint-disable no-console */ 986 | 987 | import express from 'express'; 988 | import { createServer } from 'http'; 989 | 990 | import './config/db'; 991 | import constants from './config/constants'; 992 | import middlewares from './config/middlewares'; 993 | import mocks from './mocks'; 994 | 995 | const app = express(); 996 | 997 | middlewares(app); 998 | 999 | const graphQLServer = createServer(app); 1000 | 1001 | mocks().then(() => { 1002 | graphQLServer.listen(constants.PORT, err => { 1003 | if (err) { 1004 | console.error(err); 1005 | } else { 1006 | console.log(`App listen to port: ${constants.PORT}`); 1007 | } 1008 | }); 1009 | }); 1010 | ``` 1011 | 1012 | Why do this ? Just for make my code cleaner ;) 1013 | 1014 | Now time to add a middleware inside express who gonna add the user inside the context. So this way we have access to it on every request. 1015 | 1016 | ##### src/config/middlewares.js 1017 | 1018 | ```js 1019 | import bodyParser from 'body-parser'; 1020 | import { graphiqlExpress, graphqlExpress } from 'apollo-server-express'; 1021 | import { makeExecutableSchema } from 'graphql-tools'; 1022 | 1023 | import constants from './constants'; 1024 | import typeDefs from '../graphql/schema'; 1025 | import resolvers from '../graphql/resolvers'; 1026 | import { decodeToken } from '../services/auth'; 1027 | 1028 | const schema = makeExecutableSchema({ 1029 | typeDefs, 1030 | resolvers, 1031 | }); 1032 | 1033 | async function auth(req, res, next) { 1034 | try { 1035 | const token = req.headers.authorization; 1036 | if (token != null) { 1037 | const user = await decodeToken(token); 1038 | req.user = user; // eslint-disable-line 1039 | } else { 1040 | req.user = null; // eslint-disable-line 1041 | } 1042 | next(); 1043 | } catch (error) { 1044 | throw error; 1045 | } 1046 | } 1047 | 1048 | export default app => { 1049 | app.use(bodyParser.json()); 1050 | app.use(auth); 1051 | app.use( 1052 | '/graphiql', 1053 | graphiqlExpress({ 1054 | endpointURL: constants.GRAPHQL_PATH, 1055 | }), 1056 | ); 1057 | app.use( 1058 | constants.GRAPHQL_PATH, 1059 | graphqlExpress(req => ({ 1060 | schema, 1061 | context: { 1062 | user: req.user, 1063 | }, 1064 | })), 1065 | ); 1066 | }; 1067 | ``` 1068 | 1069 | If you look at the function auth we make a middleware who check the headers authorization. If he find one he decode the token provide in it and if a user come from it he put it inside the req object. 1070 | 1071 | After inside the graphqlExpress middleware we add the req callback and we return the user inside the context. 1072 | 1073 | After we can change the resolvers for the tweet. 1074 | 1075 | ##### src/graphql/resolvers/tweet-resolvers.js 1076 | 1077 | ```js 1078 | import Tweet from '../../models/Tweet'; 1079 | import { requireAuth } from '../../services/auth'; 1080 | 1081 | export default { 1082 | getTweet: async (_, { _id }, { user }) => { 1083 | try { 1084 | await requireAuth(user); 1085 | return Tweet.findById(_id); 1086 | } catch (error) { 1087 | throw error; 1088 | } 1089 | }, 1090 | 1091 | getTweets: async (_, args, { user }) => { 1092 | try { 1093 | await requireAuth(user); 1094 | return Tweet.find({}).sort({ createdAt: -1 }); 1095 | } catch (error) { 1096 | throw error; 1097 | } 1098 | }, 1099 | 1100 | createTweet: async (_, args, { user }) => { 1101 | try { 1102 | await requireAuth(user); 1103 | return Tweet.create(args); 1104 | } catch (error) { 1105 | throw error; 1106 | } 1107 | }, 1108 | 1109 | updateTweet: async (_, { _id, ...rest }, { user }) => { 1110 | try { 1111 | await requireAuth(user); 1112 | return Tweet.findByIdAndUpdate(_id, rest, { new: true }); 1113 | } catch (error) { 1114 | throw error; 1115 | } 1116 | }, 1117 | 1118 | deleteTweet: async (_, { _id }) => { 1119 | try { 1120 | await Tweet.findByIdAndRemove(_id); 1121 | return { 1122 | message: 'Delete Success!', 1123 | }; 1124 | } catch (error) { 1125 | throw error; 1126 | } 1127 | }, 1128 | }; 1129 | ``` 1130 | 1131 | Now we make sure all this function work only on when is auth. That gonna help you a lot for add the user id inside the Tweet in the next part too. As you can see I add also some trycatch for make sure if error we throw it. 1132 | 1133 | 4. Now we can also add a resolver call me where we can receive the user object in the front end. 1134 | 1135 | ##### src/graphql/resolvers/user-resolvers.js 1136 | 1137 | ```js 1138 | import User from '../../models/User'; 1139 | import { requireAuth } from '../../services/auth'; 1140 | 1141 | export default { 1142 | signup: async (_, { fullName, ...rest }) => { 1143 | const [firstName, ...lastName] = fullName.split(' '); 1144 | try { 1145 | const user = await User.create({ firstName, lastName, ...rest }); 1146 | return { 1147 | token: user.createToken(), 1148 | }; 1149 | } catch (error) { 1150 | throw error; 1151 | } 1152 | }, 1153 | 1154 | login: async (_, { email, password }) => { 1155 | try { 1156 | const user = await User.findOne({ email }); 1157 | 1158 | if (!user) { 1159 | throw new Error('User not exist!'); 1160 | } 1161 | 1162 | if (!user.authenticateUser(password)) { 1163 | throw new Error('Password not match!'); 1164 | } 1165 | 1166 | return { 1167 | token: user.createToken(), 1168 | }; 1169 | } catch (error) { 1170 | throw error; 1171 | } 1172 | }, 1173 | 1174 | me: async (_, args, { user }) => { 1175 | try { 1176 | const me = await requireAuth(user); 1177 | return me; 1178 | } catch (error) { 1179 | throw error; 1180 | } 1181 | }, 1182 | }; 1183 | ``` 1184 | 1185 | We get the me object from the function `requireAuth` build earlier. 1186 | 1187 | Now the schema should look like this 1188 | 1189 | ##### src/graphql/schema.js 1190 | 1191 | ```js 1192 | export default` 1193 | scalar Date 1194 | 1195 | type Status { 1196 | message: String! 1197 | } 1198 | 1199 | type Auth { 1200 | token: String! 1201 | } 1202 | 1203 | type User { 1204 | _id: ID! 1205 | username: String 1206 | email: String! 1207 | firstName: String 1208 | lastName: String 1209 | avatar: String 1210 | createdAt: Date! 1211 | updatedAt: Date! 1212 | } 1213 | 1214 | type Me { 1215 | _id: ID! 1216 | username: String 1217 | email: String! 1218 | firstName: String 1219 | lastName: String 1220 | avatar: String 1221 | createdAt: Date! 1222 | updatedAt: Date! 1223 | } 1224 | 1225 | type Tweet { 1226 | _id: ID! 1227 | text: String! 1228 | createdAt: Date! 1229 | updatedAt: Date! 1230 | } 1231 | 1232 | type Query { 1233 | getTweet(_id: ID!): Tweet 1234 | getTweets: [Tweet] 1235 | me: Me 1236 | } 1237 | 1238 | type Mutation { 1239 | createTweet(text: String!): Tweet 1240 | updateTweet(_id: ID!, text: String): Tweet 1241 | deleteTweet(_id: ID!): Status 1242 | signup(email: String!, fullName: String!, password: String!, avatar: String, username: String): Auth 1243 | login(email: String!, password: String!): Auth 1244 | } 1245 | 1246 | schema { 1247 | query: Query 1248 | mutation: Mutation 1249 | } 1250 | `; 1251 | ``` 1252 | 1253 | Now you see we return a auth type not anymore the user. 1254 | 1255 | Finally inside the index resolvers we do 1256 | 1257 | ##### src/graphql/resolvers/index.js 1258 | 1259 | ```js 1260 | import GraphQLDate from 'graphql-date'; 1261 | 1262 | import TweetResolvers from './tweet-resolvers'; 1263 | import UserResolvers from './user-resolvers'; 1264 | 1265 | export default { 1266 | Date: GraphQLDate, 1267 | Query: { 1268 | getTweet: TweetResolvers.getTweet, 1269 | getTweets: TweetResolvers.getTweets, 1270 | me: UserResolvers.me 1271 | }, 1272 | Mutation: { 1273 | createTweet: TweetResolvers.createTweet, 1274 | updateTweet: TweetResolvers.updateTweet, 1275 | deleteTweet: TweetResolvers.deleteTweet, 1276 | signup: UserResolvers.signup, 1277 | login: UserResolvers.login 1278 | } 1279 | }; 1280 | ``` 1281 | 1282 | --- 1283 | 1284 | 1285 | ## Part 5 - Add tweet belongs to user 1286 | 1287 | ### Video 1288 | 1289 | [Link](https://youtu.be/G2vt7BW4Sgw) 1290 | 1291 | In this part we go over the setup for adding a reference of the user inside the tweet schema. This way we can track who is the creator of this tweet and make sure he can be the only one to update this one or delete it. 1292 | 1293 | 1. We need to change the setup of the tweet schema for mongodb. 1294 | 1295 | ##### src/models/Tweet.js 1296 | 1297 | ```js 1298 | import mongoose, { Schema } from 'mongoose'; 1299 | 1300 | const TweetSchema = new Schema({ 1301 | text: { 1302 | type: String, 1303 | minlength: [5, 'Text need to be longer'], 1304 | maxlength: [144, 'Text too long'], 1305 | }, 1306 | user: { 1307 | type: Schema.Types.ObjectId, 1308 | ref: 'User' 1309 | }, 1310 | favoriteCount: { 1311 | type: Number, 1312 | default: 0 1313 | } 1314 | }, { timestamps: true }); 1315 | 1316 | export default mongoose.model('Tweet', TweetSchema); 1317 | ``` 1318 | 1319 | Here you see I add some database validation about the text. I add a minimun of 5 and a maximum of 144 characters. The thing in the bracket with the number are the error message show by the database. 1320 | 1321 | user here are reference of the User model we create earlier. We just need to save the id here. 1322 | 1323 | 2. Add the user inside the create tweet resolvers. 1324 | 1325 | ##### src/graphql/resolvers/tweet-resolvers.js 1326 | 1327 | ```js 1328 | createTweet: async (_, args, { user }) => { 1329 | try { 1330 | await requireAuth(user); 1331 | return Tweet.create({ ...args, user: user._id }); 1332 | } catch (error) { 1333 | throw error; 1334 | } 1335 | }, 1336 | ``` 1337 | 1338 | Here I spread my args to make sure we don't add an object in a object. This way each property get out of the last object and become a part of the new one. You can see too I add the `user._id` inside the user. This is how you add references to it. But this user came from the jwt so that's why he need to be authorized. 1339 | 1340 | 3. We can do the same too with the update and delete so we can make sure the user who create it his the only who can update or delete it. 1341 | 1342 | ##### src/graphql/resolvers/tweet-resolvers.js 1343 | 1344 | ```js 1345 | updateTweet: async (_, { _id, ...rest }, { user }) => { 1346 | try { 1347 | await requireAuth(user); 1348 | const tweet = await Tweet.findOne({ _id, user: user._id }); 1349 | 1350 | if (!tweet) { 1351 | throw new Error('Not found!'); 1352 | } 1353 | 1354 | Object.entries(rest).forEach(([key, value]) => { 1355 | tweet[key] = value; 1356 | }); 1357 | 1358 | return tweet.save(); 1359 | } catch (error) { 1360 | throw error; 1361 | } 1362 | }, 1363 | deleteTweet: async (_, { _id }, { user }) => { 1364 | try { 1365 | await requireAuth(user); 1366 | const tweet = await Tweet.findOne({ _id, user: user._id }); 1367 | 1368 | if (!tweet) { 1369 | throw new Error('Not found!'); 1370 | } 1371 | await tweet.remove(); 1372 | return { 1373 | message: 'Delete Success!' 1374 | } 1375 | } catch (error) { 1376 | throw error; 1377 | } 1378 | } 1379 | ``` 1380 | 1381 | For the update we get the tweet from the promise resolve. As you can see I make use of the `findOne` function who return only one document. I search a tweet who have the tweet id provide inside the args but also the user id provide from the jwt. If I have no tweet return that mean the tweet don't exist or the tweet exist but it's not belongs the user. 1382 | 1383 | After I loop over the rest object for update the tweet with the method `Object.entries` who give you access to the key and value. And finally we return the promise `tweet.save()`. 1384 | 1385 | For the delete almost the same process but we delete it and return a message. 1386 | 1387 | 4. Now we can return an array of tweets belongs the user himself. 1388 | 1389 | ##### src/graphql/resolvers/tweet-resolvers.js 1390 | 1391 | ```js 1392 | getUserTweets: async (_, args, { user }) => { 1393 | try { 1394 | await requireAuth(user); 1395 | return Tweet.find({ user: user._id }).sort({ createdAt: -1 }) 1396 | } catch (error) { 1397 | throw error; 1398 | } 1399 | }, 1400 | ``` 1401 | 1402 | This way we can as the mobile dev take it for show the user tweets in his profile. 1403 | 1404 | But don't forget the add this to the index resolver 1405 | 1406 | ##### src/graphql/resolvers/index.js 1407 | 1408 | ```js 1409 | export default { 1410 | Date: GraphQLDate, 1411 | Tweet: { 1412 | user: ({ user }) => User.findById(user), 1413 | }, 1414 | Query: { 1415 | getTweet: TweetResolvers.getTweet, 1416 | getTweets: TweetResolvers.getTweets, 1417 | getUserTweets: TweetResolvers.getUserTweets, // here 1418 | me: UserResolvers.me 1419 | }, 1420 | Mutation: { 1421 | createTweet: TweetResolvers.createTweet, 1422 | updateTweet: TweetResolvers.updateTweet, 1423 | deleteTweet: TweetResolvers.deleteTweet, 1424 | signup: UserResolvers.signup, 1425 | login: UserResolvers.login 1426 | } 1427 | }; 1428 | ``` 1429 | 1430 | 5. Finally we can update the mocks for create the user + tweet with it. 1431 | 1432 | ##### src/mocks/index.js 1433 | 1434 | ```js 1435 | import faker from 'faker'; 1436 | 1437 | import Tweet from '../models/Tweet'; 1438 | import User from '../models/User'; 1439 | 1440 | const TWEETS_TOTAL = 3; 1441 | const USERS_TOTAL = 3; 1442 | 1443 | export default async () => { 1444 | try { 1445 | await Tweet.remove(); 1446 | await User.remove(); 1447 | 1448 | await Array.from({ length: USERS_TOTAL }).forEach(async (_, i) => { 1449 | const user = await User.create({ 1450 | username: faker.internet.userName(), 1451 | firstName: faker.name.firstName(), 1452 | lastName: faker.name.lastName(), 1453 | email: faker.internet.email(), 1454 | avatar: `https://randomuser.me/api/portraits/women/${i}.jpg`, 1455 | password: 'password123' 1456 | }); 1457 | 1458 | await Array.from({ length: TWEETS_TOTAL }).forEach( 1459 | async () => await Tweet.create({ text: faker.lorem.sentence(), user: user._id }), 1460 | ); 1461 | }); 1462 | } catch (error) { 1463 | throw error; 1464 | } 1465 | }; 1466 | ``` 1467 | 1468 | --- 1469 | 1470 | ## Part 6 - Setup of mobile side 1471 | 1472 | ### Video 1473 | 1474 | [Link](https://youtu.be/c_8M5l4itrA) 1475 | 1476 | --- 1477 | 1478 | ## Part 7 - Design of feed card 1479 | 1480 | ### Video 1481 | 1482 | [Link](https://youtu.be/Lkn1FDbUU0U) 1483 | 1484 | #### End Result Of This Episode 1485 | 1486 | 1487 | 1488 | --- 1489 | 1490 | ## Part 8 - Setup the navigations 1491 | 1492 | ### Video 1493 | 1494 | [Link](https://youtu.be/RrIPpWcuN5w) 1495 | 1496 | #### End Result Of This Episode 1497 | 1498 | Screen_Shot_2017_08_05_at_10_57_43_AM 1499 | 1500 | --- 1501 | 1502 | ## Part 9 - Connect the Mobile app with the Server 1503 | 1504 | ### Video 1505 | 1506 | [Link](https://youtu.be/LVr3qaZGqUw) 1507 | 1508 | --- 1509 | 1510 | ## Part 10 - Designing the Signup Screen 1511 | 1512 | ### Video 1513 | 1514 | [Link](https://youtu.be/Zca5Kyi9cyc) 1515 | 1516 | #### End Result Of This Episode 1517 | 1518 | Screen_Shot_2017_08_05_at_10_57_43_AM 1519 | Screen_Shot_2017_08_05_at_10_57_43_AM 1520 | 1521 | --- 1522 | 1523 | ## Part 11 - Authorization and Apollo Middleware 1524 | 1525 | ### Video 1526 | 1527 | [Link](https://youtu.be/Ttvt3JhzGQ4) 1528 | 1529 | --- 1530 | 1531 | ## Part 12 - Me Query with Header avatar and logout 1532 | 1533 | ### Video 1534 | 1535 | [Link](https://youtu.be/DDeNQ55mWTQ) 1536 | 1537 | --- 1538 | 1539 | ## Part 13 - Designing the NewTweetScreen 1540 | 1541 | ### Video 1542 | 1543 | [Link](https://youtu.be/0u_HUWkEIII) 1544 | 1545 | Screen_Shot_2017_08_05_at_10_57_43_AM 1546 | 1547 | Screen_Shot_2017_08_05_at_10_57_43_AM 1548 | 1549 | --- 1550 | 1551 | ## Part 14 - CreateTweet mutation add to the NewScreenTweet + Optimistic-ui 1552 | 1553 | ### Video 1554 | 1555 | [Link](https://youtu.be/sDCfEh6XIU0) 1556 | 1557 | ## Part 15 - Subscriptions for real time data -> TweetCreation 1558 | 1559 | ### Video 1560 | 1561 | [Link](https://youtu.be/V472NiWt2Jg) 1562 | 1563 | --- 1564 | 1565 | ## Part 16-Prelude - Talk about the Favorite Tweet features 1566 | 1567 | ### Video 1568 | 1569 | [Link](https://youtu.be/h6m1gs9OjB4) 1570 | 1571 | --- 1572 | 1573 | ## Part 16 - Code the favorite tweet features server side 1574 | 1575 | ### Video 1576 | 1577 | [Link](https://youtu.be/n8VNuRvMYv4) 1578 | 1579 | --- 1580 | 1581 | ## Part 17 - Favorite tweet mutation mobile side 1582 | 1583 | ### Video 1584 | 1585 | [Link](https://youtu.be/8zNfgm_vp3A) 1586 | 1587 | --- 1588 | 1589 | ## Part 18 - Adding ui change for the favorite tweet 1590 | 1591 | ### Video 1592 | 1593 | [Link](https://youtu.be/iAxeLDaU-ms) 1594 | 1595 | --- 1596 | 1597 | ## Part 19 - Subscription for the favorite tweet 1598 | 1599 | ### Video 1600 | 1601 | [Link](https://youtu.be/2zHBhSp8V3c) 1602 | 1603 | --- 1604 | 1605 | ## Part 20 - Placeholder for loading card 1606 | 1607 | ### Video 1608 | 1609 | [Link](https://youtu.be/vvMnG8SZfaU) 1610 | 1611 | --- 1612 | 1613 | ## Part 21 - Design of Profile page 1614 | 1615 | ### Video 1616 | 1617 | [Link](https://youtu.be/B2b4LS4WBtQ) 1618 | 1619 | --- 1620 | 1621 | ## Part 22 - Using fragments 1622 | 1623 | ### Video 1624 | 1625 | [Link](https://youtu.be/3xt9Rm31Prc) 1626 | 1627 | --- 1628 | 1629 | ## Part 23 - Creation of the following schema 1630 | 1631 | ### Video 1632 | 1633 | [Link](https://youtu.be/Ldo0SsairC4) -------------------------------------------------------------------------------- /mobile/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-expo"], 3 | "env": { 4 | "development": { 5 | "plugins": ["transform-react-jsx-source"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /mobile/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # Use 4 spaces for the Python files 13 | [*.py] 14 | indent_size = 4 15 | max_line_length = 80 16 | 17 | # The JSON files contain newlines inconsistently 18 | [*.json] 19 | insert_final_newline = ignore 20 | 21 | # Minified JavaScript files shouldn't be changed 22 | [**.min.js] 23 | indent_style = ignore 24 | insert_final_newline = ignore 25 | 26 | # Makefiles always use tabs for indentation 27 | [Makefile] 28 | indent_style = tab 29 | 30 | # Batch files use tabs for indentation 31 | [*.bat] 32 | indent_style = tab 33 | 34 | [*.md] 35 | trim_trailing_whitespace = false 36 | 37 | -------------------------------------------------------------------------------- /mobile/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "equimper", 4 | "prettier/react", 5 | "prettier" 6 | ] 7 | } -------------------------------------------------------------------------------- /mobile/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | -------------------------------------------------------------------------------- /mobile/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /mobile/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppLoading } from 'expo'; 3 | import { UIManager, AsyncStorage } from 'react-native'; 4 | import { ApolloProvider } from 'react-apollo'; 5 | import { ThemeProvider } from 'styled-components'; 6 | import { ActionSheetProvider } from '@expo/react-native-action-sheet'; 7 | 8 | import { store, client } from './src/store'; 9 | import { colors } from './src/utils/constants'; 10 | import { login } from './src/actions/user'; 11 | 12 | import AppNavigation from './src/navigations'; 13 | 14 | if (UIManager.setLayoutAnimationEnabledExperimental) { 15 | UIManager.setLayoutAnimationEnabledExperimental(true); 16 | } 17 | 18 | export default class App extends React.Component { 19 | state = { 20 | appIsReady: false, 21 | }; 22 | 23 | componentWillMount() { 24 | this._checkIfToken(); 25 | } 26 | 27 | _checkIfToken = async () => { 28 | try { 29 | const token = await AsyncStorage.getItem('@twitteryoutubeclone'); 30 | if (token != null) { 31 | store.dispatch(login()); 32 | } 33 | } catch (error) { 34 | throw error; 35 | } 36 | 37 | this.setState({ appIsReady: true }); 38 | }; 39 | 40 | render() { 41 | if (!this.state.appIsReady) { 42 | return ; 43 | } 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mobile/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "twitter-clone-graphql", 4 | "description": "An empty new project", 5 | "slug": "mobile", 6 | "privacy": "public", 7 | "sdkVersion": "19.0.0", 8 | "version": "1.0.0", 9 | "orientation": "portrait", 10 | "primaryColor": "#cccccc", 11 | "icon": "./assets/icons/app-icon.png", 12 | "loading": { 13 | "icon": "./assets/icons/loading-icon.png", 14 | "hideExponentText": false 15 | }, 16 | "packagerOpts": { 17 | "assetExts": ["ttf", "mp4"] 18 | }, 19 | "ios": { 20 | "supportsTablet": true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mobile/assets/icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EQuimper/twitter-clone-with-graphql-reactnative/32d22f1a7ccd15036f7edd1bf42c174b9316903a/mobile/assets/icons/app-icon.png -------------------------------------------------------------------------------- /mobile/assets/icons/loading-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EQuimper/twitter-clone-with-graphql-reactnative/32d22f1a7ccd15036f7edd1bf42c174b9316903a/mobile/assets/icons/loading-icon.png -------------------------------------------------------------------------------- /mobile/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /mobile/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobile", 3 | "version": "0.0.0", 4 | "description": "Hello Expo!", 5 | "author": null, 6 | "private": true, 7 | "main": "node_modules/expo/AppEntry.js", 8 | "scripts": { 9 | "debug": "open 'rndebugger://set-debugger-loc?host=localhost&port=19001'" 10 | }, 11 | "dependencies": { 12 | "@appandflow/touchable": "^1.1.1", 13 | "@expo/react-native-action-sheet": "^1.0.0", 14 | "apollo-client": "1.9.0-0", 15 | "date-fns": "^1.28.5", 16 | "expo": "^19.0.0", 17 | "react": "16.0.0-alpha.12", 18 | "react-apollo": "^1.4.8", 19 | "react-native": "https://github.com/expo/react-native/archive/sdk-19.0.0.tar.gz", 20 | "react-navigation": "^1.0.0-beta.11", 21 | "react-redux": "^5.0.5", 22 | "redux": "^3.7.2", 23 | "redux-thunk": "^2.2.0", 24 | "rn-placeholder": "^1.0.1", 25 | "styled-components": "^2.1.1", 26 | "subscriptions-transport-ws": "^0.8.2" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^4.3.0", 30 | "eslint-config-equimper": "^2.2.1", 31 | "eslint-config-prettier": "^2.3.0", 32 | "prettier": "^1.5.3", 33 | "redux-devtools-extension": "^2.13.2", 34 | "redux-logger": "^3.0.6" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /mobile/src/actions/user.js: -------------------------------------------------------------------------------- 1 | import { AsyncStorage } from 'react-native'; 2 | 3 | export function login() { 4 | return { 5 | type: 'LOGIN' 6 | } 7 | } 8 | 9 | export function getUserInfo(info) { 10 | return { 11 | type: 'GET_USER_INFO', 12 | info 13 | } 14 | } 15 | 16 | export function logout() { 17 | return async (dispatch) => { 18 | try { 19 | await AsyncStorage.removeItem('@twitteryoutubeclone'); 20 | 21 | return dispatch({ 22 | type: 'LOGOUT' 23 | }) 24 | } catch (error) { 25 | throw error; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mobile/src/components/ButtonHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | import Touchable from '@appandflow/touchable'; 4 | 5 | const Button = styled(Touchable).attrs({ 6 | feedback: 'opacity', 7 | hitSlop: { top: 20, bottom: 20, right: 20, left: 20 }, 8 | })` 9 | marginRight: ${props => props.side === 'right' ? 15 : 0}; 10 | marginLeft: ${props => props.side === 'left' ? 15 : 0}; 11 | justifyContent: center; 12 | alignItems: center; 13 | `; 14 | 15 | export default function ButtonHeader({ side, children, onPress, disabled }) { 16 | return ( 17 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /mobile/src/components/FeedCard/FeedCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { graphql, gql } from 'react-apollo'; 4 | import Placeholder from 'rn-placeholder'; 5 | 6 | import FeedCardHeader from './FeedCardHeader'; 7 | import FeedCardBottom from './FeedCardBottom'; 8 | import FAVORITE_TWEET_MUTATION from '../../graphql/mutations/favoriteTweet'; 9 | 10 | const Root = styled.View` 11 | minHeight: 180; 12 | backgroundColor: ${props => props.theme.WHITE}; 13 | width: 100%; 14 | padding: 7px; 15 | shadowColor: ${props => props.theme.SECONDARY}; 16 | shadowOffset: 0px 2px; 17 | shadowRadius: 2; 18 | shadowOpacity: 0.1; 19 | marginVertical: 5; 20 | `; 21 | 22 | const CardContentContainer = styled.View` 23 | flex: 1; 24 | padding: 10px 20px 10px 0px; 25 | `; 26 | 27 | const CardContentText = styled.Text` 28 | fontSize: 14; 29 | textAlign: left; 30 | fontWeight: 500; 31 | color: ${props => props.theme.SECONDARY}; 32 | `; 33 | 34 | const Wrapper = styled.View`flex: 1`; 35 | 36 | function FeedCard({ 37 | text, 38 | user, 39 | createdAt, 40 | favoriteCount, 41 | favorite, 42 | isFavorited, 43 | placeholder, 44 | isLoaded 45 | }) { 46 | if (placeholder) { 47 | return ( 48 | 49 | 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | return ( 62 | 63 | 64 | 65 | 66 | {text} 67 | 68 | 69 | 74 | 75 | ); 76 | } 77 | 78 | FeedCard.fragments = { 79 | tweet: gql` 80 | fragment FeedCard on Tweet { 81 | text 82 | _id 83 | createdAt 84 | isFavorited 85 | favoriteCount 86 | user { 87 | username 88 | avatar 89 | lastName 90 | firstName 91 | } 92 | } 93 | ` 94 | } 95 | 96 | export default graphql(FAVORITE_TWEET_MUTATION, { 97 | props: ({ ownProps, mutate }) => ({ 98 | favorite: () => 99 | mutate({ 100 | variables: { _id: ownProps._id }, 101 | optimisticResponse: { 102 | __typename: 'Mutation', 103 | favoriteTweet: { 104 | __typename: 'Tweet', 105 | _id: ownProps._id, 106 | favoriteCount: ownProps.isFavorited 107 | ? ownProps.favoriteCount - 1 108 | : ownProps.favoriteCount + 1, 109 | isFavorited: !ownProps.isFavorited, 110 | }, 111 | }, 112 | }), 113 | }), 114 | })(FeedCard); 115 | -------------------------------------------------------------------------------- /mobile/src/components/FeedCard/FeedCardBottom.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { SimpleLineIcons, Entypo } from '@expo/vector-icons'; 4 | import Touchable from '@appandflow/touchable'; 5 | 6 | import { colors } from '../../utils/constants'; 7 | 8 | const ICON_SIZE = 20; 9 | 10 | const Root = styled.View` 11 | height: 40; 12 | flexDirection: row; 13 | `; 14 | 15 | const Button = styled(Touchable).attrs({ 16 | feedback: 'opacity', 17 | })` 18 | flex: 1; 19 | flexDirection: row; 20 | alignItems: center; 21 | justifyContent: space-around; 22 | paddingHorizontal: 32px; 23 | `; 24 | 25 | const ButtonText = styled.Text` 26 | fontSize: 14; 27 | fontWeight: 500; 28 | color: ${props => props.theme.LIGHT_GRAY}; 29 | `; 30 | 31 | function FeedCardBottom({ favoriteCount, onFavoritePress, isFavorited }) { 32 | return ( 33 | 34 | 44 | 50 | 60 | 61 | ); 62 | } 63 | 64 | export default FeedCardBottom; 65 | -------------------------------------------------------------------------------- /mobile/src/components/FeedCard/FeedCardHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; 4 | 5 | import { fakeAvatar } from '../../utils/constants'; 6 | 7 | const AVATAR_SIZE = 40; 8 | const AVATAR_RADIUS = AVATAR_SIZE / 2; 9 | 10 | const Root = styled.View` 11 | height: 50; 12 | flexDirection: row; 13 | alignItems: center; 14 | `; 15 | 16 | const AvatarContainer = styled.View` 17 | flex: 0.2; 18 | justifyContent: center; 19 | alignSelf: stretch; 20 | `; 21 | 22 | const Avatar = styled.Image` 23 | height: ${AVATAR_SIZE}; 24 | width: ${AVATAR_SIZE}; 25 | borderRadius: ${AVATAR_RADIUS}; 26 | `; 27 | 28 | const MetaContainer = styled.View` 29 | flex: 1; 30 | alignSelf: stretch; 31 | `; 32 | 33 | const MetaTopContainer = styled.View` 34 | flex: 1; 35 | alignSelf: stretch; 36 | flexDirection: row; 37 | alignItems: center; 38 | justifyContent: flex-start; 39 | `; 40 | 41 | const MetaBottomContainer = styled.View` 42 | flex: 0.8; 43 | alignSelf: stretch; 44 | alignItems: flex-start; 45 | justifyContent: center; 46 | `; 47 | 48 | const MetaFullName = styled.Text` 49 | fontSize: 16; 50 | fontWeight: bold; 51 | color: ${props => props.theme.SECONDARY}; 52 | `; 53 | 54 | const MetaText = styled.Text` 55 | fontSize: 14; 56 | fontWeight: 600; 57 | color: ${props => props.theme.LIGHT_GRAY}; 58 | `; 59 | 60 | function FeedCardHeader({ username, firstName, lastName, avatar, createdAt }) { 61 | return ( 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {firstName} {lastName} 70 | 71 | 72 | @{username} 73 | 74 | 75 | 76 | 77 | {distanceInWordsToNow(createdAt)} ago 78 | 79 | 80 | 81 | 82 | ) 83 | } 84 | 85 | export default FeedCardHeader; 86 | -------------------------------------------------------------------------------- /mobile/src/components/HeaderAvatar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { connect } from 'react-redux'; 4 | import { withApollo } from 'react-apollo'; 5 | import { connectActionSheet } from '@expo/react-native-action-sheet'; 6 | 7 | import { logout } from '../actions/user'; 8 | 9 | import Loading from './Loading'; 10 | import ButtonHeader from './ButtonHeader'; 11 | 12 | const AVATAR_SIZE = 30; 13 | const AVATAR_RADIUS = AVATAR_SIZE / 2; 14 | 15 | const Avatar = styled.Image` 16 | height: ${AVATAR_SIZE}; 17 | width: ${AVATAR_SIZE}; 18 | borderRadius: ${AVATAR_RADIUS}; 19 | `; 20 | 21 | class HeaderAvatar extends Component { 22 | _onOpenActionSheet = () => { 23 | const options = ['Logout', 'Cancel']; 24 | const destructiveButtonIndex = 0; 25 | this.props.showActionSheetWithOptions( 26 | { 27 | options, 28 | destructiveButtonIndex, 29 | }, 30 | buttonIndex => { 31 | if (buttonIndex === 0) { 32 | this.props.client.resetStore() 33 | return this.props.logout(); 34 | } 35 | }, 36 | ); 37 | }; 38 | 39 | render() { 40 | if (!this.props.info) { 41 | return ( 42 | 43 | 44 | 45 | ); 46 | } 47 | return ( 48 | 49 | 50 | 51 | ); 52 | } 53 | } 54 | 55 | export default withApollo(connect(state => ({ info: state.user.info }), { logout })( 56 | connectActionSheet(HeaderAvatar), 57 | )); 58 | -------------------------------------------------------------------------------- /mobile/src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ActivityIndicator } from 'react-native'; 3 | import styled from 'styled-components/native'; 4 | 5 | import { colors } from '../utils/constants'; 6 | 7 | const Root = styled.View` 8 | flex: 1; 9 | justifyContent: center; 10 | alignItems: center; 11 | `; 12 | 13 | export default function Loading({ color = colors.PRIMARY, size = 'large' } = {}) { 14 | return ( 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /mobile/src/components/ProfileHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | 4 | const AVATAR_SIZE = 60; 5 | 6 | const Root = styled.View` 7 | height: 140; 8 | alignSelf: stretch; 9 | paddingTop: 50; 10 | backgroundColor: ${props => props.theme.WHITE}; 11 | `; 12 | 13 | const Heading = styled.View` 14 | flex: 1; 15 | flexDirection: row; 16 | alignItems: center; 17 | justifyContent: flex-start; 18 | paddingLeft: 15; 19 | paddingTop: 5; 20 | `; 21 | 22 | const Avatar = styled.Image` 23 | height: ${AVATAR_SIZE}; 24 | width: ${AVATAR_SIZE}; 25 | borderRadius: ${AVATAR_SIZE / 2}; 26 | backgroundColor: yellow; 27 | `; 28 | 29 | const UsernameContainer = styled.View` 30 | flex: 1; 31 | paddingLeft: 10; 32 | alignSelf: stretch; 33 | `; 34 | 35 | const FullName = styled.Text` 36 | color: ${props => props.theme.SECONDARY}; 37 | fontWeight: bold; 38 | fontSize: 18; 39 | `; 40 | 41 | const UserName = styled.Text` 42 | color: ${props => props.theme.SECONDARY}; 43 | fontSize: 15; 44 | opacity: 0.8; 45 | `; 46 | 47 | const MetaContainer = styled.View` 48 | flex: 0.8; 49 | flexDirection: row; 50 | `; 51 | 52 | const MetaBox = styled.View` 53 | flex: 1; 54 | justifyContent: center; 55 | alignItems: center; 56 | `; 57 | 58 | const MetaText = styled.Text` 59 | color: ${props => props.theme.SECONDARY}; 60 | fontSize: 16; 61 | fontWeight: 600; 62 | `; 63 | 64 | const MetaTextNumber = styled.Text`color: ${props => props.theme.PRIMARY};`; 65 | 66 | export default function ProfileHeader({ firstName, lastName, avatar, username}) { 67 | return ( 68 | 69 | 70 | 71 | 72 | 73 | {firstName} {lastName} 74 | 75 | 76 | @{username} 77 | 78 | 79 | 80 | 81 | 82 | 83 | 3 tweets 84 | 85 | 86 | 87 | 88 | 3 likes 89 | 90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /mobile/src/components/SignupForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { MaterialIcons } from '@expo/vector-icons'; 4 | import Touchable from '@appandflow/touchable'; 5 | import { Platform, Keyboard, AsyncStorage } from 'react-native'; 6 | import { graphql, compose } from 'react-apollo'; 7 | import { connect } from 'react-redux'; 8 | 9 | import { colors, fakeAvatar } from '../utils/constants'; 10 | import SIGNUP_MUTATION from '../graphql/mutations/signup'; 11 | import Loading from '../components/Loading'; 12 | import { login } from '../actions/user'; 13 | 14 | const Root = styled(Touchable).attrs({ 15 | feedback: 'none', 16 | })` 17 | flex: 1; 18 | position: relative; 19 | justifyContent: center; 20 | alignItems: center; 21 | `; 22 | 23 | const Wrapper = styled.View` 24 | alignSelf: stretch; 25 | alignItems: center; 26 | justifyContent: center; 27 | flex: 1; 28 | `; 29 | 30 | const BackButton = styled(Touchable).attrs({ 31 | feedback: 'opacity', 32 | hitSlop: { top: 20, bottom: 20, right: 20, left: 20 }, 33 | })` 34 | justifyContent: center; 35 | alignItems: center; 36 | position: absolute; 37 | top: 5%; 38 | zIndex: 1; 39 | left: 5%; 40 | `; 41 | 42 | const ButtonConfirm = styled(Touchable).attrs({ 43 | feedback: 'opacity', 44 | })` 45 | position: absolute; 46 | bottom: 15%; 47 | width: 70%; 48 | height: 50; 49 | backgroundColor: ${props => props.theme.PRIMARY}; 50 | borderRadius: 10; 51 | justifyContent: center; 52 | alignItems: center; 53 | shadowColor: #000; 54 | shadowOpacity: 0.2; 55 | shadowRadius: 5; 56 | shadowOffset: 0px 2px; 57 | elevation: 2; 58 | `; 59 | 60 | const ButtonConfirmText = styled.Text` 61 | color: ${props => props.theme.WHITE}; 62 | fontWeight: 600; 63 | `; 64 | 65 | const InputWrapper = styled.View` 66 | height: 50; 67 | width: 70%; 68 | borderBottomWidth: 2; 69 | borderBottomColor: ${props => props.theme.LIGHT_GRAY}; 70 | marginVertical: 5; 71 | justifyContent: flex-end; 72 | `; 73 | 74 | const Input = styled.TextInput.attrs({ 75 | placeholderTextColor: colors.LIGHT_GRAY, 76 | selectionColor: Platform.OS === 'ios' ? colors.PRIMARY : undefined, 77 | autoCorrect: false, 78 | })` 79 | height: 30; 80 | color: ${props => props.theme.WHITE}; 81 | `; 82 | 83 | class SignupForm extends Component { 84 | state = { 85 | fullName: '', 86 | email: '', 87 | password: '', 88 | username: '', 89 | loading: false, 90 | }; 91 | 92 | _onOutsidePress = () => Keyboard.dismiss(); 93 | 94 | _onChangeText = (text, type) => this.setState({ [type]: text }); 95 | 96 | _checkIfDisabled() { 97 | const { fullName, email, password, username } = this.state; 98 | 99 | if (!fullName || !email || !password || !username) { 100 | return true; 101 | } 102 | 103 | return false; 104 | } 105 | 106 | _onSignupPress = async () => { 107 | this.setState({ loading: true }); 108 | 109 | const { fullName, email, password, username } = this.state; 110 | const avatar = fakeAvatar; 111 | 112 | try { 113 | const { data } = await this.props.mutate({ 114 | variables: { 115 | fullName, 116 | email, 117 | password, 118 | username, 119 | avatar, 120 | }, 121 | }); 122 | await AsyncStorage.setItem('@twitteryoutubeclone', data.signup.token); 123 | this.setState({ loading: false }); 124 | return this.props.login(); 125 | } catch (error) { 126 | throw error; 127 | } 128 | }; 129 | 130 | render() { 131 | if (this.state.loading) { 132 | return ; 133 | } 134 | return ( 135 | 136 | 137 | 138 | 139 | 140 | 141 | this._onChangeText(text, 'fullName')} 145 | /> 146 | 147 | 148 | this._onChangeText(text, 'email')} 153 | /> 154 | 155 | 156 | this._onChangeText(text, 'password')} 160 | /> 161 | 162 | 163 | this._onChangeText(text, 'username')} 167 | /> 168 | 169 | 170 | 174 | Sign Up 175 | 176 | 177 | ); 178 | } 179 | } 180 | 181 | export default compose(graphql(SIGNUP_MUTATION), connect(undefined, { login }))( 182 | SignupForm, 183 | ); 184 | -------------------------------------------------------------------------------- /mobile/src/components/Welcome.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/native'; 3 | 4 | const Root = styled.View` 5 | alignItems: center; 6 | justifyContent: center; 7 | flex: 1; 8 | backgroundColor: ${props => props.theme.WHITE}; 9 | width: 90%; 10 | alignSelf: center; 11 | `; 12 | 13 | const Text = styled.Text` 14 | color: ${props => props.theme.PRIMARY}; 15 | fontSize: 18; 16 | textAlign: center; 17 | `; 18 | 19 | export default function Welcome() { 20 | return ( 21 | 22 | Welcome, if you see this that mean everything work!!! 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /mobile/src/graphql/mutations/createTweet.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | import FeedCard from '../../components/FeedCard/FeedCard'; 4 | 5 | export default gql` 6 | mutation createTweet($text: String!) { 7 | createTweet(text: $text) { 8 | ...FeedCard 9 | } 10 | } 11 | ${FeedCard.fragments.tweet} 12 | `; 13 | -------------------------------------------------------------------------------- /mobile/src/graphql/mutations/favoriteTweet.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | export default gql` 4 | mutation favoriteTweet($_id: ID!) { 5 | favoriteTweet(_id: $_id) { 6 | isFavorited 7 | favoriteCount 8 | _id 9 | } 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /mobile/src/graphql/mutations/signup.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | export default gql` 4 | mutation signup( 5 | $fullName: String! 6 | $email: String! 7 | $password: String! 8 | $username: String! 9 | $avatar: String 10 | ) { 11 | signup( 12 | fullName: $fullName 13 | email: $email 14 | password: $password 15 | username: $username 16 | avatar: $avatar 17 | ) { 18 | token 19 | } 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /mobile/src/graphql/queries/getTweets.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | import FeedCard from '../../components/FeedCard/FeedCard'; 4 | 5 | export default gql` 6 | { 7 | getTweets { 8 | ...FeedCard 9 | } 10 | } 11 | ${FeedCard.fragments.tweet} 12 | `; 13 | -------------------------------------------------------------------------------- /mobile/src/graphql/queries/getUserTweets.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | import FeedCard from '../../components/FeedCard/FeedCard'; 4 | 5 | export default gql` 6 | { 7 | getUserTweets { 8 | ...FeedCard 9 | } 10 | } 11 | ${FeedCard.fragments.tweet} 12 | `; 13 | -------------------------------------------------------------------------------- /mobile/src/graphql/queries/me.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | export default gql` 4 | { 5 | me { 6 | avatar 7 | username 8 | firstName 9 | lastName 10 | } 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /mobile/src/graphql/subscriptions/tweetAdded.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | import FeedCard from '../../components/FeedCard/FeedCard'; 4 | 5 | export default gql` 6 | subscription { 7 | tweetAdded { 8 | ...FeedCard 9 | } 10 | } 11 | ${FeedCard.fragments.tweet} 12 | `; 13 | -------------------------------------------------------------------------------- /mobile/src/graphql/subscriptions/tweetFavorited.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'react-apollo'; 2 | 3 | export default gql` 4 | subscription { 5 | tweetFavorited { 6 | _id 7 | favoriteCount 8 | } 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /mobile/src/navigations.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | addNavigationHelpers, 4 | StackNavigator, 5 | TabNavigator, 6 | } from 'react-navigation'; 7 | import { Keyboard } from 'react-native'; 8 | import { connect } from 'react-redux'; 9 | import { FontAwesome, SimpleLineIcons, EvilIcons } from '@expo/vector-icons'; 10 | 11 | import HomeScreen from './screens/HomeScreen'; 12 | import ExploreScreen from './screens/ExploreScreen'; 13 | import NotificationsScreen from './screens/NotificationsScreen'; 14 | import ProfileScreen from './screens/ProfileScreen'; 15 | import AuthenticationScreen from './screens/AuthenticationScreen'; 16 | import NewTweetScreen from './screens/NewTweetScreen'; 17 | 18 | import HeaderAvatar from './components/HeaderAvatar'; 19 | import ButtonHeader from './components/ButtonHeader'; 20 | 21 | import { colors } from './utils/constants'; 22 | 23 | const TAB_ICON_SIZE = 20; 24 | 25 | const Tabs = TabNavigator( 26 | { 27 | Home: { 28 | screen: HomeScreen, 29 | navigationOptions: () => ({ 30 | headerTitle: 'Home', 31 | tabBarIcon: ({ tintColor }) => 32 | , 33 | }), 34 | }, 35 | Explore: { 36 | screen: ExploreScreen, 37 | navigationOptions: () => ({ 38 | headerTitle: 'Explore', 39 | tabBarIcon: ({ tintColor }) => 40 | , 41 | }), 42 | }, 43 | Notifications: { 44 | screen: NotificationsScreen, 45 | navigationOptions: () => ({ 46 | headerTitle: 'Notifications', 47 | tabBarIcon: ({ tintColor }) => 48 | , 49 | }), 50 | }, 51 | Profile: { 52 | screen: ProfileScreen, 53 | navigationOptions: () => ({ 54 | headerTitle: 'Profile', 55 | tabBarIcon: ({ tintColor }) => 56 | , 57 | }), 58 | }, 59 | }, 60 | { 61 | lazy: true, 62 | tabBarPosition: 'bottom', 63 | swipeEnabled: false, 64 | tabBarOptions: { 65 | showIcon: true, 66 | showLabel: false, 67 | activeTintColor: colors.PRIMARY, 68 | inactiveTintColor: colors.LIGHT_GRAY, 69 | style: { 70 | backgroundColor: colors.WHITE, 71 | height: 50, 72 | paddingVertical: 5, 73 | }, 74 | }, 75 | }, 76 | ); 77 | 78 | const NewTweetModal = StackNavigator( 79 | { 80 | NewTweet: { 81 | screen: NewTweetScreen, 82 | navigationOptions: ({ navigation }) => ({ 83 | headerLeft: , 84 | headerRight: ( 85 | { 88 | Keyboard.dismiss(); 89 | navigation.goBack(null); 90 | }} 91 | > 92 | 93 | 94 | ), 95 | }), 96 | }, 97 | }, 98 | { 99 | headerMode: 'none', 100 | }, 101 | ); 102 | 103 | const AppMainNav = StackNavigator( 104 | { 105 | Home: { 106 | screen: Tabs, 107 | navigationOptions: ({ navigation }) => ({ 108 | headerLeft: , 109 | headerRight: ( 110 | navigation.navigate('NewTweet')} 113 | > 114 | 115 | 116 | ), 117 | }), 118 | }, 119 | NewTweet: { 120 | screen: NewTweetModal, 121 | }, 122 | }, 123 | { 124 | cardStyle: { 125 | backgroundColor: '#F1F6FA', 126 | }, 127 | navigationOptions: () => ({ 128 | headerStyle: { 129 | backgroundColor: colors.WHITE, 130 | }, 131 | headerTitleStyle: { 132 | fontWeight: 'bold', 133 | color: colors.SECONDARY, 134 | }, 135 | }), 136 | }, 137 | ); 138 | 139 | class AppNavigator extends Component { 140 | render() { 141 | const nav = addNavigationHelpers({ 142 | dispatch: this.props.dispatch, 143 | state: this.props.nav, 144 | }); 145 | if (!this.props.user.isAuthenticated) { 146 | return ; 147 | } 148 | return ; 149 | } 150 | } 151 | 152 | export default connect(state => ({ 153 | nav: state.nav, 154 | user: state.user, 155 | }))(AppNavigator); 156 | 157 | export const router = AppMainNav.router; 158 | -------------------------------------------------------------------------------- /mobile/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import nav from './navigation'; 4 | import user from './user'; 5 | 6 | export default client => combineReducers({ 7 | apollo: client.reducer(), 8 | nav, 9 | user 10 | }); 11 | -------------------------------------------------------------------------------- /mobile/src/reducers/navigation.js: -------------------------------------------------------------------------------- 1 | import { router } from '../navigations'; 2 | 3 | export default (state, action) => { 4 | const newState = router.getStateForAction(action, state); 5 | return newState || state; 6 | } 7 | -------------------------------------------------------------------------------- /mobile/src/reducers/user.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | isAuthenticated: false, 3 | info: null 4 | } 5 | 6 | export default (state = initialState, action) => { 7 | switch (action.type) { 8 | case 'LOGIN': 9 | return { 10 | ...state, 11 | isAuthenticated: true 12 | } 13 | case 'GET_USER_INFO': 14 | return { 15 | ...state, 16 | info: action.info 17 | } 18 | case 'LOGOUT': 19 | return initialState; 20 | default: 21 | return state; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /mobile/src/screens/AuthenticationScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import Touchable from '@appandflow/touchable'; 4 | 5 | import SignupForm from '../components/SignupForm'; 6 | 7 | const Root = styled.View` 8 | flex: 1; 9 | backgroundColor: ${props => props.theme.SECONDARY}; 10 | position: relative; 11 | `; 12 | 13 | const ButtonSignupText = styled.Text` 14 | color: ${props => props.theme.WHITE}; 15 | fontWeight: bold; 16 | fontSize: 20; 17 | `; 18 | 19 | const ButtonSignup = styled(Touchable).attrs({ 20 | feedback: 'opacity' 21 | })` 22 | height: 75; 23 | width: 150; 24 | backgroundColor: ${props => props.theme.PRIMARY}; 25 | justifyContent: center; 26 | alignItems: center; 27 | position: absolute; 28 | top: 30%; 29 | right: 0; 30 | borderTopLeftRadius: 20; 31 | borderBottomLeftRadius: 20; 32 | shadowOpacity: 0.4; 33 | shadowRadius: 5; 34 | shadowOffset: 0px 4px; 35 | shadowColor: #000; 36 | elevation: 2; 37 | `; 38 | 39 | const BottomTextContainer = styled.View` 40 | position: absolute; 41 | bottom: 0; 42 | left: 0; 43 | right: 0; 44 | height: 200; 45 | justifyContent: center; 46 | alignItems: center; 47 | `; 48 | 49 | const ButtonLogin = styled(Touchable).attrs({ 50 | feedback: 'opacity', 51 | hitSlop: { top: 20, bottom: 20, right: 20, left: 20 } 52 | })` 53 | justifyContent: center; 54 | alignItems: center; 55 | `; 56 | 57 | const ButtonLoginText = styled.Text` 58 | color: ${props => props.theme.WHITE}; 59 | fontWeight: 400; 60 | fontSize: 16; 61 | `; 62 | 63 | const initialState = { 64 | showSignup: false, 65 | showLogin: false, 66 | } 67 | 68 | class AuthenticationScreen extends Component { 69 | state = initialState; 70 | 71 | _onShowSignupPress = () => this.setState({ showSignup: true }); 72 | 73 | _onBackPress = () => this.setState({ ...initialState }); 74 | 75 | render() { 76 | if (this.state.showSignup) { 77 | return ( 78 | 79 | 80 | 81 | ) 82 | } 83 | return ( 84 | 85 | 86 | Get Started 87 | 88 | 89 | 90 | 91 | Already have an account? 92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | } 99 | 100 | export default AuthenticationScreen; 101 | -------------------------------------------------------------------------------- /mobile/src/screens/ExploreScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | 4 | const Root = styled.View``; 5 | 6 | const T = styled.Text`` 7 | 8 | class ExploreScreen extends Component { 9 | state = { } 10 | render() { 11 | return ( 12 | 13 | Explore 14 | 15 | ); 16 | } 17 | } 18 | 19 | export default ExploreScreen; 20 | -------------------------------------------------------------------------------- /mobile/src/screens/HomeScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { graphql, compose, withApollo } from 'react-apollo'; 4 | import { FlatList } from 'react-native'; 5 | import { connect } from 'react-redux'; 6 | 7 | import FeedCard from '../components/FeedCard/FeedCard'; 8 | 9 | import { getUserInfo } from '../actions/user'; 10 | 11 | import GET_TWEETS_QUERY from '../graphql/queries/getTweets'; 12 | import ME_QUERY from '../graphql/queries/me'; 13 | import TWEET_ADDED_SUBSCRIPTION from '../graphql/subscriptions/tweetAdded'; 14 | import TWEET_FAVORITED_SUBSCRIPTION from '../graphql/subscriptions/tweetFavorited'; 15 | 16 | const Root = styled.View` 17 | flex: 1; 18 | paddingTop: 5; 19 | `; 20 | 21 | class HomeScreen extends Component { 22 | componentWillMount() { 23 | this.props.data.subscribeToMore({ 24 | document: TWEET_ADDED_SUBSCRIPTION, 25 | updateQuery: (prev, { subscriptionData }) => { 26 | if (!subscriptionData.data) { 27 | return prev; 28 | } 29 | 30 | const newTweet = subscriptionData.data.tweetAdded; 31 | 32 | if (!prev.getTweets.find(t => t._id === newTweet._id)) { 33 | return { 34 | ...prev, 35 | getTweets: [{ ...newTweet }, ...prev.getTweets], 36 | }; 37 | } 38 | 39 | return prev; 40 | }, 41 | }); 42 | 43 | this.props.data.subscribeToMore({ 44 | document: TWEET_FAVORITED_SUBSCRIPTION, 45 | updateQuery: (prev, { subscriptionData }) => { 46 | if (!subscriptionData.data) { 47 | return prev; 48 | } 49 | 50 | const newTweet = subscriptionData.data.tweetFavorited; 51 | return { 52 | ...prev, 53 | getTweets: prev.getTweets.map( 54 | tweet => 55 | tweet._id === newTweet._id 56 | ? { 57 | ...tweet, 58 | favoriteCount: newTweet.favoriteCount, 59 | } 60 | : tweet, 61 | ), 62 | }; 63 | }, 64 | }); 65 | } 66 | 67 | componentDidMount() { 68 | this._getUserInfo(); 69 | } 70 | 71 | _getUserInfo = async () => { 72 | const { data: { me } } = await this.props.client.query({ query: ME_QUERY }); 73 | this.props.getUserInfo(me); 74 | }; 75 | 76 | _renderItem = ({ item }) => ; 77 | 78 | _renderPlaceholder = () => 79 | 80 | render() { 81 | const { data } = this.props; 82 | if (data.loading) { 83 | return ( 84 | 85 | item} 90 | /> 91 | 92 | ); 93 | } 94 | return ( 95 | 96 | item._id} 100 | renderItem={this._renderItem} 101 | /> 102 | 103 | ); 104 | } 105 | } 106 | 107 | export default withApollo( 108 | compose(connect(undefined, { getUserInfo }), graphql(GET_TWEETS_QUERY))( 109 | HomeScreen, 110 | ), 111 | ); 112 | -------------------------------------------------------------------------------- /mobile/src/screens/NewTweetScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { Platform, Keyboard } from 'react-native'; 4 | import Touchable from '@appandflow/touchable'; 5 | import { graphql, compose } from 'react-apollo'; 6 | import { connect } from 'react-redux'; 7 | 8 | import { colors } from '../utils/constants'; 9 | import CREATE_TWEET_MUTATION from '../graphql/mutations/createTweet'; 10 | import GET_TWEETS_QUERY from '../graphql/queries/getTweets'; 11 | 12 | const Root = styled.View` 13 | backgroundColor: ${props => props.theme.WHITE}; 14 | flex: 1; 15 | alignItems: center; 16 | `; 17 | 18 | const Wrapper = styled.View` 19 | height: 80%; 20 | width: 90%; 21 | paddingTop: 5; 22 | position: relative; 23 | `; 24 | 25 | const Input = styled.TextInput.attrs({ 26 | multiline: true, 27 | placeholder: "What's happening?", 28 | maxLength: 140, 29 | selectionColor: Platform.OS === 'ios' && colors.PRIMARY, 30 | autoFocus: true, 31 | })` 32 | height: 40%; 33 | width: 100%; 34 | fontSize: 18; 35 | color: ${props => props.theme.SECONDARY}; 36 | `; 37 | 38 | const TweetButton = styled(Touchable).attrs({ 39 | feedback: 'opacity', 40 | hitSlop: { top: 20, left: 20, right: 20, bottom: 20 }, 41 | })` 42 | backgroundColor: ${props => props.theme.PRIMARY}; 43 | justifyContent: center; 44 | alignItems: center; 45 | width: 80; 46 | height: 40; 47 | borderRadius: 20; 48 | position: absolute; 49 | top: 60%; 50 | right: 0; 51 | `; 52 | 53 | const TweetButtonText = styled.Text` 54 | color: ${props => props.theme.WHITE}; 55 | fontSize: 16; 56 | `; 57 | 58 | const TextLength = styled.Text` 59 | fontSize: 18; 60 | color: ${props => props.theme.PRIMARY}; 61 | position: absolute; 62 | top: 45%; 63 | right: 5%; 64 | `; 65 | 66 | class NewTweetScreen extends Component { 67 | state = { 68 | text: '', 69 | }; 70 | 71 | _onChangeText = text => this.setState({ text }); 72 | 73 | _onCreateTweetPress = async () => { 74 | const { user } = this.props; 75 | 76 | await this.props.mutate({ 77 | variables: { 78 | text: this.state.text 79 | }, 80 | optimisticResponse: { 81 | __typename: 'Mutation', 82 | createTweet: { 83 | __typename: 'Tweet', 84 | text: this.state.text, 85 | favoriteCount: 0, 86 | _id: Math.round(Math.random() * -1000000), 87 | createdAt: new Date(), 88 | isFavorited: false, 89 | user: { 90 | __typename: 'User', 91 | username: user.username, 92 | firstName: user.firstName, 93 | lastName: user.lastName, 94 | avatar: user.avatar 95 | } 96 | }, 97 | }, 98 | update: (store, { data: { createTweet } }) => { 99 | const data = store.readQuery({ query: GET_TWEETS_QUERY }); 100 | if (!data.getTweets.find(t => t._id === createTweet._id)) { 101 | store.writeQuery({ query: GET_TWEETS_QUERY, data: { getTweets: [{ ...createTweet }, ...data.getTweets] } }); 102 | } 103 | } 104 | }); 105 | 106 | Keyboard.dismiss(); 107 | this.props.navigation.goBack(null); 108 | } 109 | 110 | get _textLength() { 111 | return 140 - this.state.text.length; 112 | } 113 | 114 | get _buttonDisabled() { 115 | return this.state.text.length < 5; 116 | } 117 | 118 | render() { 119 | return ( 120 | 121 | 122 | 123 | 124 | {this._textLength} 125 | 126 | 127 | Tweet 128 | 129 | 130 | 131 | ); 132 | } 133 | } 134 | 135 | export default compose( 136 | graphql(CREATE_TWEET_MUTATION), 137 | connect(state => ({ user: state.user.info })) 138 | )(NewTweetScreen); 139 | -------------------------------------------------------------------------------- /mobile/src/screens/NotificationsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | 4 | const Root = styled.View``; 5 | 6 | const T = styled.Text`` 7 | 8 | class NotificationsScreen extends Component { 9 | state = { } 10 | render() { 11 | return ( 12 | 13 | Notification 14 | 15 | ); 16 | } 17 | } 18 | 19 | export default NotificationsScreen; 20 | -------------------------------------------------------------------------------- /mobile/src/screens/ProfileScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components/native'; 3 | import { connect } from 'react-redux'; 4 | import { graphql, compose } from 'react-apollo'; 5 | import { FlatList } from 'react-native'; 6 | 7 | import ProfileHeader from '../components/ProfileHeader'; 8 | import FeedCard from '../components/FeedCard/FeedCard'; 9 | 10 | import GET_USER_TWEETS_QUERY from '../graphql/queries/getUserTweets'; 11 | 12 | const Root = styled.View` 13 | flex: 1; 14 | backgroundColor: #f1f6fa; 15 | `; 16 | 17 | const T = styled.Text`` 18 | 19 | class ProfileScreen extends Component { 20 | state = { } 21 | 22 | _renderItem = ({ item }) => ; 23 | 24 | _renderPlaceholder = () => ( 25 | 29 | ) 30 | 31 | render() { 32 | const { info, data } = this.props; 33 | 34 | return ( 35 | 36 | 37 | {data.loading ? ( 38 | item} 42 | contentContainerStyle={{ alignSelf: 'stretch' }} 43 | /> 44 | ) : ( 45 | item._id} 49 | contentContainerStyle={{ alignSelf: 'stretch' }} 50 | /> 51 | )} 52 | 53 | ); 54 | } 55 | } 56 | 57 | export default compose( 58 | graphql(GET_USER_TWEETS_QUERY), 59 | connect(state => ({ info: state.user.info }), 60 | ))(ProfileScreen); 61 | -------------------------------------------------------------------------------- /mobile/src/store.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import { AsyncStorage } from 'react-native'; 5 | import { composeWithDevTools } from 'redux-devtools-extension'; 6 | import ApolloClient, { createNetworkInterface } from 'apollo-client'; 7 | import thunk from 'redux-thunk'; 8 | import { createLogger } from 'redux-logger'; 9 | import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws'; 10 | 11 | import reducers from './reducers'; 12 | 13 | const networkInterface = createNetworkInterface({ 14 | uri: 'http://localhost:3000/graphql', 15 | }); 16 | 17 | const wsClient = new SubscriptionClient('ws://localhost:3000/subscriptions', { 18 | reconnect: true, 19 | connectionParams: {} 20 | }) 21 | 22 | networkInterface.use([{ 23 | async applyMiddleware(req, next) { 24 | if (!req.options.headers) { 25 | req.options.headers = {}; 26 | } 27 | try { 28 | const token = await AsyncStorage.getItem('@twitteryoutubeclone'); 29 | if (token != null) { 30 | req.options.headers.authorization = `Bearer ${token}` || null; 31 | } 32 | } catch (error) { 33 | throw error; 34 | } 35 | 36 | return next(); 37 | } 38 | }]) 39 | 40 | const networkInterfaceWithSubs = addGraphQLSubscriptions( 41 | networkInterface, 42 | wsClient 43 | ) 44 | 45 | export const client = new ApolloClient({ 46 | networkInterface: networkInterfaceWithSubs 47 | }); 48 | 49 | const middlewares = [client.middleware(), thunk, createLogger()]; 50 | 51 | export const store = createStore( 52 | reducers(client), 53 | undefined, 54 | composeWithDevTools(applyMiddleware(...middlewares)), 55 | ); 56 | -------------------------------------------------------------------------------- /mobile/src/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | PRIMARY: '#55ACEE', 3 | SECONDARY: '#444B52', 4 | WHITE: '#FFFFFF', 5 | LIGHT_GRAY: '#CAD0D6', 6 | }; 7 | 8 | export const fakeAvatar = 'https://pbs.twimg.com/profile_images/835144746217664515/oxBgzjRA_bigger.jpg'; 9 | -------------------------------------------------------------------------------- /server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": "6.10" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "transform-object-rest-spread", 15 | { 16 | "useBuiltIns": true 17 | } 18 | ] 19 | ] 20 | } -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "equimper", 4 | "prettier" 5 | ] 6 | } -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /server/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=dev nodemon --exec babel-node src/index.js", 8 | "prettier": "prettier --single-quote --print-width 80 --trailing-comma all --write 'src/**/*.js'" 9 | }, 10 | "dependencies": { 11 | "apollo-server-express": "^1.0.2", 12 | "bcrypt-nodejs": "^0.0.3", 13 | "body-parser": "^1.17.2", 14 | "cross-env": "^5.0.1", 15 | "express": "^4.15.3", 16 | "faker": "^4.1.0", 17 | "graphql": "^0.10.5", 18 | "graphql-date": "^1.0.3", 19 | "graphql-subscriptions": "^0.4.4", 20 | "graphql-tools": "^1.1.0", 21 | "jsonwebtoken": "^7.4.1", 22 | "mongoose": "^4.11.3", 23 | "subscriptions-transport-ws": "^0.8.2" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "^6.24.1", 27 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 28 | "babel-preset-env": "^1.6.0", 29 | "eslint": "^4.2.0", 30 | "eslint-config-equimper": "^2.2.1", 31 | "eslint-config-prettier": "^2.3.0", 32 | "nodemon": "^1.11.0", 33 | "prettier": "^1.5.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/src/config/constants.js: -------------------------------------------------------------------------------- 1 | export default { 2 | PORT: process.env.PORT || 3000, 3 | DB_URL: 'mongodb://localhost/tweet-development', 4 | GRAPHQL_PATH: '/graphql', 5 | JWT_SECRET: 'thisisasecret123', 6 | SUBSCRIPTIONS_PATH: '/subscriptions' 7 | }; 8 | -------------------------------------------------------------------------------- /server/src/config/db.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import mongoose from 'mongoose'; 4 | 5 | import constants from './constants'; 6 | 7 | mongoose.Promise = global.Promise; 8 | 9 | mongoose.set('debug', true); // debug mode on 10 | 11 | try { 12 | mongoose.connect(constants.DB_URL, { 13 | useMongoClient: true, 14 | }); 15 | } catch (err) { 16 | mongoose.createConnection(constants.DB_URL, { 17 | useMongoClient: true, 18 | }); 19 | } 20 | 21 | mongoose.connection 22 | .once('open', () => console.log('MongoDB Running')) 23 | .on('error', e => { 24 | throw e; 25 | }); 26 | -------------------------------------------------------------------------------- /server/src/config/middlewares.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import bodyParser from 'body-parser'; 4 | 5 | import { decodeToken } from '../services/auth'; 6 | 7 | async function auth(req, res, next) { 8 | try { 9 | const token = req.headers.authorization; 10 | if (token != null) { 11 | const user = await decodeToken(token); 12 | req.user = user; 13 | } else { 14 | req.user = null; 15 | } 16 | return next(); 17 | } catch (error) { 18 | throw error; 19 | } 20 | } 21 | 22 | export default app => { 23 | app.use(bodyParser.json()); 24 | app.use(auth); 25 | } -------------------------------------------------------------------------------- /server/src/config/pubsub.js: -------------------------------------------------------------------------------- 1 | import { PubSub } from 'graphql-subscriptions'; 2 | 3 | export const pubsub = new PubSub(); -------------------------------------------------------------------------------- /server/src/graphql/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import GraphQLDate from 'graphql-date'; 2 | 3 | import TweetResolvers from './tweet-resolvers'; 4 | import UserResolvers from './user-resolvers'; 5 | import User from '../../models/User'; 6 | 7 | export default { 8 | Date: GraphQLDate, 9 | Tweet: { 10 | user: ({ user }) => User.findById(user), 11 | }, 12 | Query: { 13 | getTweet: TweetResolvers.getTweet, 14 | getTweets: TweetResolvers.getTweets, 15 | getUserTweets: TweetResolvers.getUserTweets, 16 | me: UserResolvers.me 17 | }, 18 | Mutation: { 19 | createTweet: TweetResolvers.createTweet, 20 | updateTweet: TweetResolvers.updateTweet, 21 | deleteTweet: TweetResolvers.deleteTweet, 22 | favoriteTweet: TweetResolvers.favoriteTweet, 23 | signup: UserResolvers.signup, 24 | login: UserResolvers.login 25 | }, 26 | Subscription: { 27 | tweetAdded: TweetResolvers.tweetAdded, 28 | tweetFavorited: TweetResolvers.tweetFavorited 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /server/src/graphql/resolvers/tweet-resolvers.js: -------------------------------------------------------------------------------- 1 | import Tweet from '../../models/Tweet'; 2 | import FavoriteTweet from '../../models/FavoriteTweet'; 3 | import { requireAuth } from '../../services/auth'; 4 | import { pubsub } from '../../config/pubsub'; 5 | 6 | const TWEET_ADDED = 'tweetAdded'; 7 | export const TWEET_FAVORITED = 'tweetFavorited'; 8 | 9 | export default { 10 | getTweet: async (_, { _id }, { user }) => { 11 | try { 12 | await requireAuth(user); 13 | return Tweet.findById(_id); 14 | } catch (error) { 15 | throw error; 16 | } 17 | }, 18 | getTweets: async (_, args, { user }) => { 19 | try { 20 | await requireAuth(user); 21 | const p1 = Tweet.find({}).sort({ createdAt: -1 }); 22 | const p2 = FavoriteTweet.findOne({ userId: user._id }); 23 | const [tweets, favorites] = await Promise.all([p1, p2]); 24 | 25 | const tweetsToSend = tweets.reduce((arr, tweet) => { 26 | const tw = tweet.toJSON(); 27 | 28 | if (favorites.tweets.some(t => t.equals(tweet._id))) { 29 | arr.push({ 30 | ...tw, 31 | isFavorited: true, 32 | }); 33 | } else { 34 | arr.push({ 35 | ...tw, 36 | isFavorited: false, 37 | }) 38 | } 39 | 40 | return arr; 41 | }, []); 42 | 43 | return tweetsToSend; 44 | } catch (error) { 45 | throw error; 46 | } 47 | }, 48 | getUserTweets: async (_, args, { user }) => { 49 | try { 50 | await requireAuth(user); 51 | return Tweet.find({ user: user._id }).sort({ createdAt: -1 }) 52 | } catch (error) { 53 | throw error; 54 | } 55 | }, 56 | createTweet: async (_, args, { user }) => { 57 | try { 58 | await requireAuth(user); 59 | const tweet = await Tweet.create({ ...args, user: user._id }); 60 | 61 | pubsub.publish(TWEET_ADDED, { [TWEET_ADDED]: tweet }); 62 | 63 | return tweet; 64 | } catch (error) { 65 | throw error; 66 | } 67 | }, 68 | updateTweet: async (_, { _id, ...rest }, { user }) => { 69 | try { 70 | await requireAuth(user); 71 | const tweet = await Tweet.findOne({ _id, user: user._id }); 72 | 73 | if (!tweet) { 74 | throw new Error('Not found!'); 75 | } 76 | 77 | Object.entries(rest).forEach(([key, value]) => { 78 | tweet[key] = value; 79 | }); 80 | 81 | return tweet.save(); 82 | } catch (error) { 83 | throw error; 84 | } 85 | }, 86 | deleteTweet: async (_, { _id }, { user }) => { 87 | try { 88 | await requireAuth(user); 89 | const tweet = await Tweet.findOne({ _id, user: user._id }); 90 | 91 | if (!tweet) { 92 | throw new Error('Not found!'); 93 | } 94 | await tweet.remove(); 95 | return { 96 | message: 'Delete Success!' 97 | } 98 | } catch (error) { 99 | throw error; 100 | } 101 | }, 102 | favoriteTweet: async (_, { _id }, { user }) => { 103 | try { 104 | await requireAuth(user); 105 | const favorites = await FavoriteTweet.findOne({ userId: user._id }); 106 | 107 | return favorites.userFavoritedTweet(_id); 108 | } catch (error) { 109 | throw error; 110 | } 111 | }, 112 | tweetAdded: { 113 | subscribe: () => pubsub.asyncIterator(TWEET_ADDED) 114 | }, 115 | tweetFavorited: { 116 | subscribe: () => pubsub.asyncIterator(TWEET_FAVORITED), 117 | } 118 | }; 119 | -------------------------------------------------------------------------------- /server/src/graphql/resolvers/user-resolvers.js: -------------------------------------------------------------------------------- 1 | import User from '../../models/User'; 2 | import FavoriteTweet from '../../models/FavoriteTweet'; 3 | import FollowingUser from '../../models/FollowingUser'; 4 | import { requireAuth } from '../../services/auth'; 5 | 6 | export default { 7 | signup: async (_, { fullName, ...rest }) => { 8 | try { 9 | const [firstName, ...lastName] = fullName.split(' '); 10 | const user = await User.create({ firstName, lastName, ...rest }); 11 | await FavoriteTweet.create({ userId: user._id }); 12 | await FollowingUser.create({ userId: user._id }); 13 | 14 | return { 15 | token: user.createToken(), 16 | }; 17 | } catch (error) { 18 | throw error; 19 | } 20 | }, 21 | 22 | login: async (_, { email, password }) => { 23 | try { 24 | const user = await User.findOne({ email }); 25 | 26 | if (!user) { 27 | throw new Error('User not exist!'); 28 | } 29 | 30 | if (!user.authenticateUser(password)) { 31 | throw new Error('Password not match!'); 32 | } 33 | 34 | return { 35 | token: user.createToken() 36 | }; 37 | } catch (error) { 38 | throw error; 39 | } 40 | }, 41 | 42 | me: async (_, args, { user }) => { 43 | try { 44 | const me = await requireAuth(user); 45 | 46 | return me; 47 | } catch (error) { 48 | throw error; 49 | } 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /server/src/graphql/schema.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | scalar Date 3 | 4 | type Status { 5 | message: String! 6 | } 7 | 8 | type Auth { 9 | token: String! 10 | } 11 | 12 | type User { 13 | _id: ID! 14 | username: String 15 | email: String! 16 | firstName: String 17 | lastName: String 18 | avatar: String 19 | createdAt: Date! 20 | updatedAt: Date! 21 | } 22 | 23 | type Me { 24 | _id: ID! 25 | username: String 26 | email: String! 27 | firstName: String 28 | lastName: String 29 | avatar: String 30 | createdAt: Date! 31 | updatedAt: Date! 32 | } 33 | 34 | type Tweet { 35 | _id: ID! 36 | text: String! 37 | user: User! 38 | favoriteCount: Int! 39 | isFavorited: Boolean 40 | createdAt: Date! 41 | updatedAt: Date! 42 | } 43 | 44 | type Query { 45 | getTweet(_id: ID!): Tweet 46 | getTweets: [Tweet] 47 | getUserTweets: [Tweet] 48 | me: Me 49 | } 50 | 51 | type Mutation { 52 | createTweet(text: String!): Tweet 53 | updateTweet(_id: ID!, text: String): Tweet 54 | deleteTweet(_id: ID!): Status 55 | favoriteTweet(_id: ID!): Tweet 56 | signup(email: String!, fullName: String!, password: String!, avatar: String, username: String): Auth 57 | login(email: String!, password: String!): Auth 58 | } 59 | 60 | type Subscription { 61 | tweetAdded: Tweet 62 | tweetFavorited: Tweet 63 | } 64 | 65 | schema { 66 | query: Query 67 | mutation: Mutation 68 | subscription: Subscription 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import express from 'express'; 4 | import { createServer } from 'http'; 5 | import { graphiqlExpress, graphqlExpress } from 'apollo-server-express'; 6 | import { makeExecutableSchema } from 'graphql-tools'; 7 | import { SubscriptionServer } from 'subscriptions-transport-ws'; 8 | import { execute, subscribe } from 'graphql'; 9 | 10 | import './config/db'; 11 | import typeDefs from './graphql/schema'; 12 | import resolvers from './graphql/resolvers'; 13 | import constants from './config/constants'; 14 | import middlewares from './config/middlewares'; 15 | import mocks from './mocks'; 16 | 17 | const app = express(); 18 | 19 | middlewares(app); 20 | 21 | app.use((req, res, next) => setTimeout(next, 0)); 22 | 23 | app.use( 24 | '/graphiql', 25 | graphiqlExpress({ 26 | endpointURL: constants.GRAPHQL_PATH, 27 | subscriptionsEndpoint: `ws://localhost:${constants.PORT}${constants.SUBSCRIPTIONS_PATH}` 28 | }), 29 | ); 30 | 31 | const schema = makeExecutableSchema({ 32 | typeDefs, 33 | resolvers, 34 | }); 35 | 36 | app.use( 37 | constants.GRAPHQL_PATH, 38 | graphqlExpress(req => ({ 39 | schema, 40 | context: { 41 | user: req.user 42 | } 43 | })), 44 | ); 45 | 46 | const graphQLServer = createServer(app); 47 | 48 | // mocks().then(() => { 49 | graphQLServer.listen(constants.PORT, err => { 50 | if (err) { 51 | console.error(err); 52 | } else { 53 | new SubscriptionServer({ // eslint-disable-line 54 | schema, 55 | execute, 56 | subscribe 57 | }, { 58 | server: graphQLServer, 59 | path: constants.SUBSCRIPTIONS_PATH 60 | }) 61 | 62 | console.log(`App listen to port: ${constants.PORT}`); 63 | } 64 | }); 65 | // }); 66 | -------------------------------------------------------------------------------- /server/src/mocks/index.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker'; 2 | 3 | import Tweet from '../models/Tweet'; 4 | import User from '../models/User'; 5 | 6 | const TWEETS_TOTAL = 3; 7 | const USERS_TOTAL = 3; 8 | 9 | export default async () => { 10 | try { 11 | await Tweet.remove(); 12 | await User.remove(); 13 | 14 | await Array.from({ length: USERS_TOTAL }).forEach(async (_, i) => { 15 | const user = await User.create({ 16 | username: faker.internet.userName(), 17 | firstName: faker.name.firstName(), 18 | lastName: faker.name.lastName(), 19 | email: faker.internet.email(), 20 | avatar: `https://randomuser.me/api/portraits/women/${i}.jpg`, 21 | password: 'password123' 22 | }); 23 | 24 | await Array.from({ length: TWEETS_TOTAL }).forEach( 25 | async () => await Tweet.create({ text: faker.lorem.sentence(), user: user._id }), 26 | ); 27 | }); 28 | } catch (error) { 29 | throw error; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /server/src/models/FavoriteTweet.js: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | import Tweet from './Tweet'; 4 | import { TWEET_FAVORITED } from '../graphql/resolvers/tweet-resolvers'; 5 | import { pubsub } from '../config/pubsub'; 6 | 7 | const FavoriteTweetSchema = new Schema({ 8 | userId: { 9 | type: Schema.Types.ObjectId, 10 | ref: 'User', 11 | }, 12 | tweets: [ 13 | { 14 | type: Schema.Types.ObjectId, 15 | ref: 'Tweet', 16 | }, 17 | ], 18 | }); 19 | 20 | FavoriteTweetSchema.methods = { 21 | async userFavoritedTweet(tweetId) { 22 | if (this.tweets.some(t => t.equals(tweetId))) { 23 | this.tweets.pull(tweetId); 24 | await this.save(); 25 | 26 | const tweet = await Tweet.decFavoriteCount(tweetId); 27 | 28 | const t = tweet.toJSON(); 29 | 30 | pubsub.publish(TWEET_FAVORITED, { [TWEET_FAVORITED]: { ...t } }); 31 | 32 | return { 33 | isFavorited: false, 34 | ...t, 35 | }; 36 | } 37 | 38 | const tweet = await Tweet.incFavoriteCount(tweetId); 39 | 40 | const t = tweet.toJSON(); 41 | 42 | this.tweets.push(tweetId); 43 | await this.save(); 44 | pubsub.publish(TWEET_FAVORITED, { [TWEET_FAVORITED]: { ...t } }); 45 | return { 46 | isFavorited: true, 47 | ...t, 48 | }; 49 | }, 50 | }; 51 | 52 | FavoriteTweetSchema.index({ userId: 1 }, { unique: true }); 53 | 54 | export default mongoose.model('FavoriteTweet', FavoriteTweetSchema); 55 | -------------------------------------------------------------------------------- /server/src/models/FollowingUser.js: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | const FollowingUserSchema = new Schema({ 4 | userId: { 5 | type: Schema.Types.ObjectId, 6 | ref: 'User', 7 | unique: true 8 | }, 9 | followings: [ 10 | { 11 | type: Schema.Types.ObjectId, 12 | ref: 'User' 13 | } 14 | ] 15 | }); 16 | 17 | FollowingUserSchema.index({ userId: 1 }, { unique: true }); 18 | 19 | export default mongoose.model('FollowingUser', FollowingUserSchema); -------------------------------------------------------------------------------- /server/src/models/Tweet.js: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | const TweetSchema = new Schema({ 4 | text: { 5 | type: String, 6 | minlength: [5, 'Text need to be longer'], 7 | maxlength: [144, 'Text too long'], 8 | }, 9 | user: { 10 | type: Schema.Types.ObjectId, 11 | ref: 'User' 12 | }, 13 | favoriteCount: { 14 | type: Number, 15 | default: 0 16 | } 17 | }, { timestamps: true }); 18 | 19 | TweetSchema.statics = { 20 | incFavoriteCount(tweetId) { 21 | return this.findByIdAndUpdate(tweetId, { $inc: { favoriteCount: 1 } }, { new: true }); 22 | }, 23 | decFavoriteCount(tweetId) { 24 | return this.findByIdAndUpdate(tweetId, { $inc: { favoriteCount: -1 } }, { new: true }); 25 | } 26 | } 27 | 28 | export default mongoose.model('Tweet', TweetSchema); 29 | -------------------------------------------------------------------------------- /server/src/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | import { hashSync, compareSync } from 'bcrypt-nodejs'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | import constants from '../config/constants'; 6 | 7 | const UserSchema = new Schema({ 8 | username: { 9 | type: String, 10 | unique: true 11 | }, 12 | firstName: String, 13 | lastName: String, 14 | avatar: String, 15 | password: String, 16 | email: String, 17 | followingsCount: { 18 | type: Number, 19 | default: 0 20 | }, 21 | followersCount: { 22 | type: Number, 23 | default: 0 24 | }, 25 | }, { timestamps: true }); 26 | 27 | UserSchema.pre('save', function(next) { 28 | if (this.isModified('password')) { 29 | this.password = this._hashPassword(this.password); 30 | return next(); 31 | } 32 | 33 | return next(); 34 | }); 35 | 36 | UserSchema.methods = { 37 | _hashPassword(password) { 38 | return hashSync(password); 39 | }, 40 | authenticateUser(password) { 41 | return compareSync(password, this.password); 42 | }, 43 | createToken() { 44 | return jwt.sign( 45 | { 46 | _id: this._id 47 | }, 48 | constants.JWT_SECRET 49 | ) 50 | } 51 | } 52 | 53 | export default mongoose.model('User', UserSchema); -------------------------------------------------------------------------------- /server/src/services/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import constants from '../config/constants'; 4 | import User from '../models/User'; 5 | 6 | export async function requireAuth(user) { 7 | if (!user || !user._id) { 8 | throw new Error('Unauthorized!'); 9 | } 10 | 11 | const me = await User.findById(user._id); 12 | 13 | if (!me) { 14 | throw new Error('Unauthorized!'); 15 | } 16 | 17 | return me; 18 | } 19 | 20 | export function decodeToken(token) { 21 | const arr = token.split(' '); 22 | 23 | if (arr[0] === 'Bearer') { 24 | return jwt.verify(arr[1], constants.JWT_SECRET); 25 | } 26 | 27 | throw new Error('Token not valid!'); 28 | } --------------------------------------------------------------------------------