├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── actions │ ├── users.js │ ├── authedUser.js │ ├── shared.js │ └── tweets.js ├── middleware │ ├── index.js │ └── logger.js ├── reducers │ ├── authedUser.js │ ├── users.js │ ├── index.js │ └── tweets.js ├── utils │ ├── api.js │ ├── helpers.js │ └── _DATA.js ├── components │ ├── Nav.js │ ├── Dashboard.js │ ├── TweetPage.js │ ├── App.js │ ├── NewTweet.js │ └── Tweet.js ├── index.js └── index.css ├── .gitignore ├── README.md └── package.json /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psatler/twitter-clone-react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/actions/users.js: -------------------------------------------------------------------------------- 1 | export const RECEIVE_USERS = "RECEIVE_USERS"; 2 | 3 | export function receiveUsers(users) { 4 | return { 5 | type: RECEIVE_USERS, 6 | users 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/actions/authedUser.js: -------------------------------------------------------------------------------- 1 | export const SET_AUTHED_USER = "SET_AUTHED_USER"; 2 | 3 | export function setAuthedUser(id) { 4 | return { 5 | type: SET_AUTHED_USER, 6 | id 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | import thunk from "redux-thunk"; 2 | import logger from "./logger"; 3 | 4 | import { applyMiddleware } from "redux"; 5 | 6 | export default applyMiddleware(thunk, logger); 7 | -------------------------------------------------------------------------------- /src/reducers/authedUser.js: -------------------------------------------------------------------------------- 1 | import { SET_AUTHED_USER } from "../actions/authedUser"; 2 | 3 | export default function authedUser(state = null, action) { 4 | switch (action.type) { 5 | case SET_AUTHED_USER: 6 | return action.id; 7 | 8 | default: 9 | return state; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/middleware/logger.js: -------------------------------------------------------------------------------- 1 | const logger = store => next => action => { 2 | console.group(action.type); 3 | console.log("The action: ", action); 4 | const returnValue = next(action); 5 | console.log("The new state: ", store.getState()); 6 | console.groupEnd(); 7 | 8 | return returnValue; 9 | }; 10 | 11 | export default logger; 12 | -------------------------------------------------------------------------------- /src/reducers/users.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_USERS } from "../actions/users"; 2 | 3 | //creating the user reducer 4 | export default function users(state = {}, action) { 5 | switch (action.type) { 6 | case RECEIVE_USERS: 7 | return { 8 | ...state, 9 | ...action.users 10 | }; 11 | 12 | default: 13 | return state; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import authedUser from "./authedUser"; 4 | import users from "./users"; 5 | import tweets from "./tweets"; 6 | 7 | import { loadingBarReducer } from "react-redux-loading"; 8 | 9 | export default combineReducers({ 10 | authedUser, 11 | users, 12 | tweets, 13 | loadingBar: loadingBarReducer 14 | }); 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/api.js: -------------------------------------------------------------------------------- 1 | import { 2 | _getUsers, 3 | _getTweets, 4 | _saveLikeToggle, 5 | _saveTweet, 6 | } from './_DATA.js' 7 | 8 | export function getInitialData () { 9 | return Promise.all([ 10 | _getUsers(), 11 | _getTweets(), 12 | ]).then(([users, tweets]) => ({ 13 | users, 14 | tweets, 15 | })) 16 | } 17 | 18 | export function saveLikeToggle (info) { 19 | return _saveLikeToggle(info) 20 | } 21 | 22 | export function saveTweet (info) { 23 | return _saveTweet(info) 24 | } -------------------------------------------------------------------------------- /src/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | 4 | export default function Nav() { 5 | return ( 6 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./components/App"; 5 | 6 | //store stuff 7 | import { createStore } from "redux"; 8 | import { Provider } from "react-redux"; 9 | import reducer from "./reducers"; //importing the default export from index inside reducers folder 10 | 11 | //importing middlewares 12 | import middleware from "./middleware"; 13 | 14 | const store = createStore(reducer, middleware); 15 | 16 | ReactDOM.render( 17 | 18 | 19 | , 20 | document.getElementById("root") 21 | ); 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Tweet-like app 2 | 3 | # How to run 4 | 5 | Do the following: 6 | 7 | ``` 8 | git clone 9 | npm install 10 | npm start 11 | ``` 12 | 13 | ## Store architecture 14 | 15 | The architecture of the redux store is as follows: 16 | 17 | ```js 18 | { 19 | tweets: { 20 | tweetId: { tweetId, authorId, timestamp, text, likes, replies, replyingTo}, 21 | tweetId: { tweetId, authorId, timestamp, text, likes, replies, replyingTo} 22 | }, 23 | users: { 24 | userId: {userId, userName, avatar, tweets array}, 25 | userId: {userId, userName, avatar, tweets array} 26 | }, 27 | authedUser: userId 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.2.0", 7 | "react-dom": "^16.2.0", 8 | "react-icons": "^3.2.2", 9 | "react-redux": "^5.1.0", 10 | "react-redux-loading": "^1.0.1", 11 | "react-router-dom": "^4.3.1", 12 | "react-scripts": "1.1.1", 13 | "redux": "^4.0.1", 14 | "redux-thunk": "^2.3.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | export function formatDate (timestamp) { 2 | const d = new Date(timestamp) 3 | const time = d.toLocaleTimeString('en-US') 4 | return time.substr(0, 5) + time.slice(-2) + ' | ' + d.toLocaleDateString() 5 | } 6 | 7 | export function formatTweet (tweet, author, authedUser, parentTweet) { 8 | const { id, likes, replies, text, timestamp } = tweet 9 | const { name, avatarURL } = author 10 | 11 | return { 12 | name, 13 | id, 14 | timestamp, 15 | text, 16 | avatar: avatarURL, 17 | likes: likes.length, 18 | replies: replies.length, 19 | hasLiked: likes.includes(authedUser), 20 | parent: !parentTweet ? null : { 21 | author: parentTweet.author, 22 | id: parentTweet.id, 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/actions/shared.js: -------------------------------------------------------------------------------- 1 | import { getInitialData } from "../utils/api"; 2 | 3 | //importing action creators 4 | import { receiveTweets } from "./tweets"; 5 | import { receiveUsers } from "./users"; 6 | import { setAuthedUser } from "./authedUser"; 7 | 8 | //importing action creators of loading bar 9 | import { showLoading, hideLoading } from "react-redux-loading"; 10 | 11 | //hard-coding an autherized user 12 | const AUTHED_ID = "tylermcginnis"; 13 | 14 | export function handleInitialData() { 15 | return dispatch => { 16 | //before retrieving info, show loading bar 17 | dispatch(showLoading()); 18 | 19 | return getInitialData().then(({ users, tweets }) => { 20 | dispatch(receiveTweets(tweets)); 21 | dispatch(receiveUsers(users)); 22 | dispatch(setAuthedUser(AUTHED_ID)); //hard-coded above 23 | 24 | //after everything has loaded, hide loading bar 25 | dispatch(hideLoading()); 26 | }); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import Tweet from "./Tweet"; 5 | 6 | class Dashboard extends Component { 7 | render() { 8 | console.log(this.props); 9 | return ( 10 |
11 |

Your Timeline

12 | 20 |
21 | ); 22 | } 23 | } 24 | 25 | //destructuring tweets from state 26 | function mapStateToProps({ tweets }) { 27 | return { 28 | tweetsIds: Object.keys(tweets).sort( 29 | //sorting from the newest to the oldest tweet 30 | //If compareFunction(a, b) is greater than 0, sort b to an index lower than a, i.e. b comes first. 31 | (a, b) => tweets[b].timestamp - tweets[a].timestamp 32 | ) 33 | }; 34 | } 35 | 36 | export default connect(mapStateToProps)(Dashboard); 37 | -------------------------------------------------------------------------------- /src/components/TweetPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import Tweet from "./Tweet"; 5 | import NewTweet from "./NewTweet"; 6 | 7 | class TweetPage extends Component { 8 | render() { 9 | const { id, replies } = this.props; 10 | 11 | return ( 12 |
13 | 14 | {/* passing the parent tweet id */} 15 | 16 | 17 | {replies.length !== 0 &&

Replies

} 18 |
    19 | {replies.map(replyId => ( 20 |
  • 21 | 22 |
  • 23 | ))} 24 |
25 |
26 | ); 27 | } 28 | } 29 | 30 | function mapStateToProps({ authedUser, tweets, users }, props) { 31 | const { id } = props.match.params; 32 | 33 | return { 34 | id, 35 | replies: !tweets[id] 36 | ? [] //if doesn't exist a tweet with this id, the reply will be an empty array 37 | : tweets[id].replies.sort( 38 | (a, b) => tweets[b].timestamp - tweets[a].timestamp 39 | ) 40 | }; 41 | } 42 | 43 | export default connect(mapStateToProps)(TweetPage); 44 | -------------------------------------------------------------------------------- /src/reducers/tweets.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_TWEETS, TOGGLE_TWEET, ADD_TWEET } from "../actions/tweets"; 2 | 3 | export default function tweets(state = {}, action) { 4 | switch (action.type) { 5 | case RECEIVE_TWEETS: 6 | return { 7 | ...state, 8 | ...action.tweets 9 | }; 10 | 11 | case ADD_TWEET: 12 | const { tweet } = action; //getting the newly added tweet from action 13 | 14 | let replyingTo = {}; 15 | if (tweet.replyingTo !== null) { 16 | replyingTo = { 17 | [tweet.replyingTo]: { 18 | //id of the tweet we are replying to 19 | ...state[tweet.replyingTo], //everything that was before 20 | replies: state[tweet.replyingTo].replies.concat([tweet.id]) 21 | } 22 | }; 23 | } 24 | 25 | return { 26 | ...state, 27 | [action.tweet.id]: action.tweet, 28 | ...replyingTo 29 | }; 30 | 31 | case TOGGLE_TWEET: 32 | return { 33 | ...state, 34 | [action.id]: { 35 | ...state[action.id], 36 | likes: 37 | action.hasLiked === true 38 | ? state[action.id].likes.filter(uid => uid !== action.authedUser) //if has liked, remove it (dislike it) 39 | : state[action.id].likes.concat([action.authedUser]) 40 | } 41 | }; 42 | 43 | default: 44 | return state; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { BrowserRouter as Router, Route } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import { handleInitialData } from "../actions/shared"; 5 | 6 | import LoadingBar from "react-redux-loading"; //importing the loading bar given by react-redux-loading 7 | 8 | import Dashboard from "./Dashboard"; 9 | import NewTweet from "./NewTweet"; 10 | import TweetPage from "./TweetPage"; 11 | import Nav from "./Nav"; 12 | 13 | class App extends Component { 14 | componentDidMount() { 15 | this.props.dispatch(handleInitialData()); 16 | } 17 | 18 | render() { 19 | return ( 20 | 21 | {/* using a fragment so we don't add another element (div) to the DOM */} 22 | 23 | 24 |
25 |
34 |
35 |
36 | ); 37 | } 38 | } 39 | 40 | function mapStateToProps({ authedUser }) { 41 | return { 42 | loading: authedUser === null 43 | }; 44 | } 45 | 46 | export default connect(mapStateToProps)(App); 47 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/actions/tweets.js: -------------------------------------------------------------------------------- 1 | import { saveLikeToggle, saveTweet } from "../utils/api"; 2 | 3 | //importing loading bar to show when we submit a tweet 4 | import { showLoading, hideLoading } from "react-redux-loading"; 5 | 6 | export const RECEIVE_TWEETS = "RECEIVE_TWEETS"; 7 | export const TOGGLE_TWEET = "TOGGLE_TWEET"; 8 | export const ADD_TWEET = "ADD_TWEET"; 9 | 10 | function addTweet(tweet) { 11 | return { 12 | type: ADD_TWEET, 13 | tweet 14 | }; 15 | } 16 | 17 | //args: tweet text and the tweet that the newTweet is replying to, if any 18 | export function handleAddTweet(text, replyingTo) { 19 | //using getState to get the current state of our store 20 | return (dispatch, getState) => { 21 | const { authedUser } = getState(); 22 | dispatch(showLoading()); //show loading bar 23 | return saveTweet({ 24 | text, 25 | author: authedUser, 26 | replyingTo 27 | }) 28 | .then(tweet => dispatch(addTweet(tweet))) 29 | .then(() => dispatch(hideLoading())); 30 | }; 31 | } 32 | 33 | //action creator 34 | export function receiveTweets(tweets) { 35 | return { 36 | type: RECEIVE_TWEETS, 37 | tweets 38 | }; 39 | } 40 | 41 | //functions for toggling tweet likes 42 | function toggleTweet({ id, authedUser, hasLiked }) { 43 | return { 44 | type: TOGGLE_TWEET, 45 | id, 46 | authedUser, 47 | hasLiked 48 | }; 49 | } 50 | //assynchronous action creator (which is exported) 51 | export function handleToggleTweet(info) { 52 | return dispatch => { 53 | //using optimistic updates here, so first we toggle the tweet and then update the backend (server) 54 | dispatch(toggleTweet(info)); 55 | 56 | //now update inside server and watch for possible errors 57 | return saveLikeToggle(info).catch(e => { 58 | console.warn("Error in handleToggleTweet ", e); 59 | dispatch(toggleTweet(info)); //resetting back to what it was initially 60 | alert("There was an error liking the tweet. Try again!"); 61 | }); 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/NewTweet.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | import { handleAddTweet } from "../actions/tweets"; 4 | 5 | import { Redirect } from "react-router-dom"; 6 | 7 | class NewTweet extends Component { 8 | state = { 9 | text: "", 10 | toHome: false 11 | }; 12 | 13 | handleChange = e => { 14 | const text = e.target.value; 15 | 16 | this.setState(() => ({ 17 | text 18 | })); 19 | }; 20 | 21 | handleSubmit = e => { 22 | e.preventDefault(); 23 | const { text } = this.state; 24 | 25 | //if we are at route /new, there is no id, so we are not replying to any tweet 26 | //if we are at route /tweet/:id, we are replying to that id 27 | const { dispatch, id } = this.props; //if id is a thing, it means we are replying to this id 28 | 29 | //todo: Add tweet to store 30 | dispatch(handleAddTweet(text, id)); 31 | // console.log("New Tweet: ", text); 32 | 33 | //reset state to default 34 | this.setState(() => ({ 35 | text: "", 36 | toHome: id ? false : true //if id is a thing, do not redirect, otherwise, you are at /new, so, after submit, redirect back to home 37 | })); 38 | }; 39 | 40 | render() { 41 | const { text, toHome } = this.state; 42 | const tweetLeft = 280 - text.length; 43 | 44 | // redirect to home view if submitted from /new 45 | if (toHome === true) { 46 | return ; 47 | } 48 | 49 | return ( 50 |
51 |

Compose new Tweet

52 |
53 |