├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── docs ├── tclone-demo.gif └── tclone-demo2.gif ├── jsconfig.json ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── img │ ├── default-profile-vector.svg │ ├── explore-thumb-vector-teal.svg │ ├── explore-thumb-vector.svg │ └── login-thumb-vector.svg ├── index.html ├── manifest.json └── robots.txt └── src ├── api └── index.js ├── comps ├── FollowButton.js ├── Heading.js ├── MultiMedia.js ├── PostText.js ├── PostsList.js ├── ScrollManager.js ├── ScrollToTop.js ├── SearchBar.js ├── Spinner.js ├── TryAgain.js ├── UsersList.js ├── chat-room-placeholder.js ├── post-tag.js ├── prompt-modal.js ├── quoted-post.js ├── splash.js ├── user-link.js └── with-urls.js ├── features ├── alerts │ └── alertsContext.js ├── notify │ ├── notify-page.js │ └── notifySlice.js ├── posts │ ├── Compose.js │ ├── Feed.js │ ├── PostDetail.js │ ├── ReactionsBar.js │ ├── compose-modal.js │ ├── postsSlice.js │ └── utils.js ├── search │ ├── Search.js │ └── searchSlice.js ├── settings │ ├── profile-modal.js │ └── settings-page.js ├── trends │ ├── Trends.js │ └── trendsSlice.js └── users │ ├── UserDetail.js │ ├── UserSuggests.js │ ├── post-likes.js │ ├── post-reposts.js │ ├── user-followers.js │ ├── user-friends.js │ └── usersSlice.js ├── index.js ├── index.test.js ├── layouts ├── header │ ├── bottom-nav.js │ └── index.js ├── landing │ ├── Login.js │ ├── Navbar.js │ └── Signup.js └── main │ ├── Explore.js │ ├── Home.js │ ├── index.js │ └── sidebar │ ├── FollowCard.js │ ├── TrendingCard.js │ └── index.js ├── pages ├── App.js └── Landing.js ├── service-worker.js ├── serviceWorkerReg.js ├── setupTests.js ├── store ├── authSlice.js └── index.js ├── styles ├── custom.scss └── main.scss ├── subscription.js └── utils └── helpers.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # vscode settings 27 | .vscode/ 28 | debug.log 29 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "arrowParens": "avoid", 4 | "printWidth": 100, 5 | "semi": false, 6 | "singleQuote": true 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Launch Edge", 5 | "request": "launch", 6 | "type": "pwa-msedge", 7 | "url": "http://localhost:3000", 8 | "webRoot": "${workspaceFolder}" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 muzamil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Netlify Status](https://api.netlify.com/api/v1/badges/28362dd5-9756-4aac-b840-d99fcd6d3a5d/deploy-status)](https://app.netlify.com/sites/tclone/deploys) 2 | 3 | ## React front-end for tclone 4 | 5 | Try it [here](https://tclone.muzam1l.com) 6 | 7 | Back-end repo [here](https://github.com/muzam1l/tclone-api) 8 | 9 | 10 | demo 11 | 12 | This is my take on building a Twitter clone, I have tried to keep things simple and concise. With minimal modules, it is very lightweight and fast, yet very functional and feature-rich. 13 | 14 | ## Things working ⚡ 15 | 16 | - **State management** (using redux-toolkit) 17 | Most of the state is global in the redux store. *Posts*, *Users*, etc are in normalized form (using `createAdapter`) and accessed using *selectors* by using the `CRUD` methods. Thus sticking solely to the *DUCKS* file pattern and redux-toolkit environment. 18 | 19 | - **Authentication** (passport) 20 | Authentication is done with passport `local-strategy` with sessions managed [server](https://github.com/muzam1l/tclone-api)-side via `httpOnly` cookies. The authentication state is also stored in sessionStorage for snappy reloads while also checking for session validity asynchronously. 21 | 22 | - **Search** (mongodb native) 23 | You can search for text in the posts or for hashtags (by prefixing search query with `#`) and for users/user mentions (by prefixing query with `@)`. Search is done using mongodb's search index queries. 24 | 25 | - **Notifications** (native and push) 26 | When enabled, it sends push notifications about replies, likes, follows, and things like that. Recent notifications can also be accessed from the `Notifications` page. 27 | 28 | - **Trends and User suggestions** (_It ain't much but it's honest work_) 29 | *Hashtags* with more frequent and recent posts are parsed and stored as trends, shown in the sidebar or on explore page. Users that you may not follow are also listed in 'Who to follow' banner on the sidebar. Trends are almost realtime, so go on and rise your hashtag to the trending section 💥. 30 | 31 | - **Composing posts** 32 | Though Posts are primarily text-based and concise, you can also preview the target posts when using 'Reply Posts' and 'Quote Posts'. Posts are also parsed for *#hashtags* and *@usernames* that you can click on. There is also the [emoji-mart](https://www.npmjs.com/package/emoji-mart) Emoji picker for handy emoji insertion. Link previews are also shown in posts using [react-tiny-link](https://www.npmjs.com/package/react-tiny-link). 33 | 34 | - **Styling** (bootstrap) 35 | Styling is done with bootstrap. Bootstrap customization is mostly done by overriding Sass variables, extending classes, and custom classes. Responsiveness is always kept in mind, so this also looks good on mobile devices. 36 | 37 | ## TODO's 38 | 39 | - [x] Likes, comments on the Posts, and maybe retweets. 40 | - [x] Using Modals and popovers' for things like Post composition and hovering on User Profile for User detail. 41 | - [x] Notifications and improved engagement. 42 | - [ ] Toasts for some events. 43 | - [ ] Dark mode. 44 | - [ ] Cool new features that even Twitter would want to borrow 😎. 45 | 46 | ## Contributing 47 | 48 | Do it 💥. 49 | 50 | ## Deploying 51 | 52 | The current setup has a front-end deployed on Netlify connecting to the server via netlify redirects. It needs `VAPID` public keys corresponding to server keys for push notifications, and a server address to connect to. Below is what your environmental variables should be like. 53 | 54 | ```sh 55 | REACT_APP_PUBLIC_VAPID_KEY= 56 | REACT_APP_API_SERVER= 57 | ``` 58 | Note: `REACT_APP_API_SERVER` key is not used in fetch calls, instead it is used in netlify redirects. On local, fetch calls are proxied via the `proxy` key in `package.json`. So if you plan to deploy it anywhere else, do the necessary changes accordingly. 59 | 60 | Install deps: `npm install`. 61 | Development server: `npm run start`. 62 | Build: `npm run build` 63 | 64 | For info on deploying the server and generating the VAPID keys, check out the [tclone-api](https://github.com/muzam1l/tclone-api#deploying) repo. 65 | 66 | ## Footnote 67 | 68 | If you are still reading this, get a life dude! 69 | 70 | -------------------------------------------------------------------------------- /docs/tclone-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/docs/tclone-demo.gif -------------------------------------------------------------------------------- /docs/tclone-demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/docs/tclone-demo2.gif -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": [ 6 | "src" 7 | ] 8 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "build/" 3 | command = "sed -i s@API_PLACEHOLDER@${REACT_APP_API_SERVER}@g netlify.toml && npm run build" 4 | [[redirects]] 5 | from = "/api/*" 6 | to = "API_PLACEHOLDER/api/:splat" 7 | status = 200 8 | [[redirects]] 9 | from = "/auth/*" 10 | to = "API_PLACEHOLDER/auth/:splat" 11 | status = 200 12 | [[redirects]] 13 | from = "/img/*" 14 | to = "API_PLACEHOLDER/img/:splat" 15 | status = 200 16 | 17 | [[redirects]] 18 | from = "/*" 19 | to = "/index.html" 20 | status = 200 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tclone", 3 | "sideEffects": false, 4 | "version": "2.2.4", 5 | "proxy": "https://tclone-api.azurewebsites.net", 6 | "dependencies": { 7 | "@fortawesome/fontawesome-svg-core": "^1.2.28", 8 | "@fortawesome/free-brands-svg-icons": "^5.13.0", 9 | "@fortawesome/free-regular-svg-icons": "^5.13.0", 10 | "@fortawesome/free-solid-svg-icons": "^5.13.0", 11 | "@fortawesome/react-fontawesome": "^0.1.9", 12 | "@reduxjs/toolkit": "^1.6.0", 13 | "@testing-library/jest-dom": "^4.2.4", 14 | "@testing-library/react": "^9.5.0", 15 | "@testing-library/user-event": "^7.2.1", 16 | "anchorme": "^3.0.5", 17 | "bootstrap": "^4.5.2", 18 | "dompurify": "^2.1.1", 19 | "emoji-mart": "^3.0.0", 20 | "get-urls": "^12.1.0", 21 | "html-escaper": "^3.0.0", 22 | "react": "^16.13.1", 23 | "react-bootstrap": "^1.3.0", 24 | "react-bottom-scroll-listener": "^4.1.0", 25 | "react-color": "^2.18.1", 26 | "react-dom": "^16.13.1", 27 | "react-redux": "^7.2.1", 28 | "react-responsive": "^8.2.0", 29 | "react-router-dom": "^5.2.0", 30 | "react-scripts": "^5.0.1", 31 | "react-time-ago": "^6.0.1", 32 | "react-tiny-link": "^3.6.0", 33 | "sass": "^1.57.1", 34 | "url": "^0.11.0" 35 | }, 36 | "scripts": { 37 | "start": "REACT_APP_VERSION=$npm_package_version react-scripts start", 38 | "build": "GENERATE_SOURCEMAP=false REACT_APP_VERSION=$npm_package_version react-scripts build", 39 | "test": "react-scripts test", 40 | "eject": "react-scripts eject" 41 | }, 42 | "eslintConfig": { 43 | "extends": "react-app", 44 | "rules": { 45 | "import/no-anonymous-default-export": "off" 46 | } 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.5%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "redux-devtools": "^3.6.1" 62 | }, 63 | "overrides": { 64 | "autoprefixer": "10.4.5" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muzam1l/tclone/e669ab0289986c89e9a251e405975f4cb4e97135/public/favicon.ico -------------------------------------------------------------------------------- /public/img/default-profile-vector.svg: -------------------------------------------------------------------------------- 1 | default-profile-vector -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 18 | 19 | 28 | Tclone - the twitter clone 29 | 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Tclone", 3 | "name": "Tclone - the twitter clone", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "48x48 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "/android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#3eaaee", 24 | "background_color": "#3eaaee" 25 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import { logout } from 'store/authSlice' 2 | 3 | /** 4 | * 5 | * request - general method for all requests handling authorization within 6 | * @param {String} url url to fetch 7 | * @param {{ dispatch: Function, body: Object, headers: Object}} data adding data for request 8 | * @returns {Promise} 9 | */ 10 | export async function request(url, { dispatch, body, headers } = {}) { 11 | let res = await fetch(url, { 12 | method: body !== undefined ? 'POST' : 'GET', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | ...headers, 16 | }, 17 | body: JSON.stringify(body), 18 | }) 19 | if (res.ok) { 20 | return res.json() 21 | } else if (res.status === 401) { 22 | await dispatch(logout()) 23 | throw Error('Not Authorized') 24 | } else throw Error('Something went wrong') 25 | } 26 | -------------------------------------------------------------------------------- /src/comps/FollowButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import { Button } from 'react-bootstrap' 4 | import { useSelector } from 'react-redux' 5 | import { useAlerts } from 'features/alerts/alertsContext' 6 | 7 | export default props => { 8 | let { isAuthenticated, user: AuthUser } = useSelector(state => state.auth) 9 | const alerts = useAlerts() 10 | let ensureNotifPermission; 11 | /** 12 | * dirty fix, as in unauthenticated, this button wont be visble (hence no handleFollow call, below) 13 | * but body still executes, giving error 14 | */ 15 | if (alerts) 16 | ensureNotifPermission = alerts.ensureNotifPermission 17 | 18 | let { followUser, user, unFollowUser } = props 19 | let { following } = user; 20 | let [hoverText, setHoverText] = React.useState('') 21 | let [hoverVariant, setHoverVariant] = React.useState('') 22 | let handleFollow = async e => { 23 | e.preventDefault() 24 | followUser(user.screen_name) 25 | ensureNotifPermission() 26 | } 27 | let handleUnFollow = async e => { 28 | e.preventDefault() 29 | unFollowUser(user.screen_name) 30 | setHoverText("Unfollowed") 31 | } 32 | let handleMouseEnter = useCallback(async _ => { 33 | following && setHoverText("Unfollow") 34 | following && setHoverVariant('danger') 35 | }, [following]) 36 | let handleMouseLeave = async _ => { 37 | setHoverText('') 38 | setHoverVariant('') 39 | } 40 | let text = !following ? "Follow" : "Following" 41 | let variant = following ? "primary" : "outline-primary" 42 | if (!isAuthenticated 43 | || (AuthUser && AuthUser.screen_name === user.screen_name)) 44 | return <> 45 | return (<> 46 | 54 | ) 55 | } -------------------------------------------------------------------------------- /src/comps/Heading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { useHistory } from 'react-router-dom' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | import { logout } from 'store/authSlice' 6 | 7 | import { Link } from 'react-router-dom' 8 | import { useMediaQuery } from 'react-responsive' 9 | import { Row, Figure } from 'react-bootstrap' 10 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 11 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft' 12 | 13 | function Heading(props) { 14 | let { title, btnLogout, backButton, btnProfile } = props 15 | 16 | let dispatch = useDispatch() 17 | let history = useHistory() 18 | const isMobile = useMediaQuery({ query: '(max-width: 576px)' }) 19 | let { user: authUser, isAuthenticated } = useSelector(state => state.auth) 20 | let [btnTxt, setBtnTxt] = React.useState("Don't click") 21 | if (backButton) 22 | backButton = () 27 | if (btnLogout) 28 | btnLogout = () 34 | if (btnProfile && isAuthenticated) 35 | btnProfile = ( 36 | 40 |
44 | 48 |
49 | 50 | ) 51 | return ( 52 |
53 | 54 | {backButton} 55 | {isMobile && btnProfile} 56 |
{title}
57 |
58 | {btnLogout} 59 |
60 | ) 61 | } 62 | export default Heading -------------------------------------------------------------------------------- /src/comps/MultiMedia.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Image } from 'react-bootstrap' 3 | import { ReactTinyLink } from 'react-tiny-link' 4 | import getUrls from 'get-urls' 5 | 6 | function MM(props) { 7 | let { post, expanded = false, className } = props 8 | let style = { 9 | card: { 10 | maxHeight: !expanded ? '350' : 'fit-content', 11 | overflow: 'hidden', 12 | }, 13 | } 14 | let { entities = {}, text } = post 15 | let { 16 | media: [photo] = [], 17 | urls: [url], 18 | } = entities 19 | if (photo) { 20 | photo = media preview 21 | } 22 | if (!url) { 23 | // TODO see if this even necessary 24 | let unparsed_urls = Array.from(getUrls(text)) 25 | if (unparsed_urls.length) { 26 | url = { 27 | expanded_url: unparsed_urls[0], // just the first one 28 | } 29 | } 30 | } 31 | if (url) { 32 | url = ( 33 | 42 | ) 43 | } 44 | if (photo || url) 45 | return ( 46 | 47 | {photo} 48 |
{url}
49 |
50 | ) 51 | else return <> 52 | } 53 | 54 | export default MM 55 | -------------------------------------------------------------------------------- /src/comps/PostText.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import WithUrls from 'comps/with-urls' 3 | import { truncateText } from 'utils/helpers' 4 | import { useHistory } from 'react-router-dom' 5 | 6 | /** 7 | * React Router onclick workaround 8 | * @example
9 | */ 10 | const OnClick = (() => { 11 | let clickTime = 0; 12 | let pos = { x: 0, y: 0 }; 13 | 14 | return onClick => ({ 15 | onMouseDown: ({ nativeEvent: e }) => { clickTime = Date.now(); pos.x = e.x; pos.y = e.y; }, 16 | onMouseUp: ({ nativeEvent: e }) => { Date.now() - clickTime < 500 && pos.x === e.x && pos.y === e.y && e.which === 1 && onClick() }, 17 | }); 18 | })(); 19 | 20 | export default ({ post, expanded = false, to }) => { 21 | const history = useHistory() 22 | let { text } = post 23 | if (!expanded) 24 | text = truncateText(text, 5) 25 | return (
{ to && history.push(to) })}> 26 | {text} 27 |
) 28 | } 29 | -------------------------------------------------------------------------------- /src/comps/PostsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect, useCallback } from 'react' 3 | import ReactTimeAgo from 'react-time-ago' 4 | import { Link } from 'react-router-dom' 5 | import { Media, Row, ListGroup, Figure } from 'react-bootstrap' 6 | import MultiMedia from 'comps/MultiMedia' 7 | import Spinner from 'comps/Spinner' 8 | import ReactionsBar from 'features/posts/ReactionsBar' 9 | import PostText from 'comps/PostText' 10 | import QuotedPost from 'comps/quoted-post' 11 | import UserLink from 'comps/user-link' 12 | import PostTag from 'comps/post-tag' 13 | 14 | import { useBottomScrollListener } from 'react-bottom-scroll-listener' 15 | import TryAgain from './TryAgain' 16 | 17 | export default function PostsList(props) { 18 | let { posts = [], status, getPosts, no_reply_tag } = props 19 | 20 | /* 21 | Not the best implementation, but I dont want to spend hours to check if changing it breaks anything 22 | */ 23 | // eslint-disable-next-line 24 | useEffect(useCallback(() => { 25 | if ((status === 'idle' || status === 'done') && !posts.length) { 26 | getPosts() 27 | // console.log('fetching on posts load, status:', status) 28 | } 29 | }, [status, posts, getPosts]), [getPosts]) 30 | useBottomScrollListener(useCallback(() => { 31 | if (status === "idle" && posts.length) { 32 | getPosts() 33 | console.log('loading more posts, status:', status) 34 | } 35 | }, [status, posts, getPosts]), 700, 200, null, true) 36 | if (status === 'loading' && !posts.length) 37 | return 38 | return ( 39 | 40 | {(posts.length > 0) ? posts.map(post => { 41 | let { retweeted_by } = post 42 | return ( 43 | 50 | 51 | 52 | 53 | 54 | 55 | 60 |
64 | 68 |
69 |
70 | 71 | 72 | 76 | {post.user.name} 77 | 78 | {/* tick */} 79 | @{post.user.screen_name} 80 |
{" - "}
81 | 82 |
83 |
84 | 85 |
86 | 87 | 90 | 91 | 92 | 93 | 94 | 95 |
96 |
97 |
98 | ) 99 | }) : (status === 'idle' && 100 |
No posts for you right now
101 | )} 102 | {status === 'loading' && } 103 | {status === 'error' && } 104 |
105 | ) 106 | } -------------------------------------------------------------------------------- /src/comps/ScrollManager.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License 3 | Copyright (c) Jeff Hansen 2018 to present. 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 7 | */ 8 | 9 | import React from 'react' 10 | import requestAnimationFrame from 'raf' 11 | 12 | export const memoryStore = { 13 | _data: new Map(), 14 | get(key) { 15 | if (!key) { 16 | return null 17 | } 18 | 19 | return this._data.get(key) || null 20 | }, 21 | set(key, data) { 22 | if (!key) { 23 | return 24 | } 25 | return this._data.set(key, data) 26 | } 27 | } 28 | 29 | /** 30 | * Component that will save and restore Window scroll position. 31 | */ 32 | export default class ScrollPositionManager extends React.Component { 33 | constructor(props) { 34 | super(...arguments) 35 | this.connectScrollTarget = this.connectScrollTarget.bind(this) 36 | this._target = window 37 | } 38 | 39 | connectScrollTarget(node) { 40 | this._target = node 41 | } 42 | 43 | restoreScrollPosition(pos) { 44 | pos = pos || this.props.scrollStore.get(this.props.scrollKey) 45 | if (this._target && pos) { 46 | requestAnimationFrame(() => { 47 | scroll(this._target, pos.x, pos.y + 236) 48 | }) 49 | } 50 | } 51 | 52 | saveScrollPosition(key) { 53 | if (this._target) { 54 | const pos = getScrollPosition(this._target) 55 | key = key || this.props.scrollKey 56 | this.props.scrollStore.set(key, pos) 57 | } 58 | } 59 | 60 | componentDidMount() { 61 | this.restoreScrollPosition() 62 | } 63 | 64 | componentWillReceiveProps(nextProps) { 65 | if (this.props.scrollKey !== nextProps.scrollKey) { 66 | this.saveScrollPosition() 67 | } 68 | } 69 | 70 | componentDidUpdate(prevProps) { 71 | if (this.props.scrollKey !== prevProps.scrollKey) { 72 | this.restoreScrollPosition() 73 | } 74 | } 75 | 76 | componentWillUnmount() { 77 | this.saveScrollPosition() 78 | } 79 | 80 | render() { 81 | const { children = null, ...props } = this.props 82 | return ( 83 | children && 84 | children({ ...props, connectScrollTarget: this.connectScrollTarget }) 85 | ) 86 | } 87 | } 88 | 89 | ScrollPositionManager.defaultProps = { 90 | scrollStore: memoryStore 91 | } 92 | 93 | function scroll(target, x, y) { 94 | if (target instanceof window.Window) { 95 | target.scrollTo(x, y) 96 | } else { 97 | target.scrollLeft = x 98 | target.scrollTop = y 99 | } 100 | } 101 | 102 | function getScrollPosition(target) { 103 | if (target instanceof window.Window) { 104 | return { x: target.scrollX, y: target.scrollY } 105 | } 106 | 107 | return { x: target.scrollLeft, y: target.scrollTop } 108 | } -------------------------------------------------------------------------------- /src/comps/ScrollToTop.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | import requestAnimationFrame from 'raf' 4 | 5 | export default function ScrollToTop() { 6 | const { pathname } = useLocation(); 7 | 8 | useEffect(() => { 9 | requestAnimationFrame(() => { 10 | window.scrollTo(0, 0); 11 | }) 12 | }, [pathname]); 13 | 14 | return null; 15 | } -------------------------------------------------------------------------------- /src/comps/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useState } from 'react' 3 | 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch' 6 | 7 | import { useHistory } from 'react-router-dom' 8 | import { Form, InputGroup } from 'react-bootstrap' 9 | 10 | function SearchBar(props) { 11 | let history = useHistory() 12 | let [value, setValue] = useState('') 13 | let handleChange = ({ target: { value } }) => { 14 | setValue(value) 15 | } 16 | let handleSubmit = (e) => { 17 | e.preventDefault(); 18 | let value = e.target.search.value; 19 | value = encodeURIComponent(value); 20 | history.push(`/search?q=${value}`) 21 | } 22 | let { className } = props; 23 | return ( 24 |
25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 43 | 44 | 45 |
46 | ) 47 | } 48 | export default SearchBar; -------------------------------------------------------------------------------- /src/comps/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Spinner, Col } from 'react-bootstrap' 3 | 4 | export default function (color) { 5 | return ( 6 | 7 | 8 | Loading... 9 | 10 | 11 | ) 12 | } -------------------------------------------------------------------------------- /src/comps/TryAgain.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faRedo } from '@fortawesome/free-solid-svg-icons/faRedo' 5 | import { Col } from 'react-bootstrap' 6 | 7 | function TryAgain(props) { 8 | return ( 9 | <> 10 | 11 |
{props.message || 'Something went wrong'}
12 | 19 | 20 | 21 | 22 | ) 23 | } 24 | export default TryAgain; -------------------------------------------------------------------------------- /src/comps/UsersList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect, useCallback } from 'react' 3 | import FollowButton from 'comps/FollowButton' 4 | import { Link } from 'react-router-dom' 5 | import { ListGroup, Media, Row, Col } from 'react-bootstrap' 6 | import UserLink from 'comps/user-link' 7 | import Spinner from 'comps/Spinner' 8 | import TryAgain from './TryAgain' 9 | import { useBottomScrollListener } from 'react-bottom-scroll-listener' 10 | 11 | import { truncateText } from 'utils/helpers' 12 | 13 | export default props => { 14 | let { 15 | users, 16 | status, 17 | getUsers, 18 | followUser, 19 | unFollowUser, 20 | className, 21 | length, 22 | compact, 23 | noPop 24 | } = props 25 | /* 26 | Not the best implementation, but I dont want to spend hours to check if changing it breaks anything 27 | */ 28 | // eslint-disable-next-line 29 | useEffect(useCallback(() => { 30 | if ((status === 'idle' || status === 'done') && !users.length) { 31 | getUsers() 32 | console.log('fetching on users load, status:', status) 33 | } 34 | }, [status, users, getUsers]), [getUsers]) 35 | useBottomScrollListener(useCallback(() => { 36 | if (status === "idle" && users.length) { 37 | getUsers() 38 | console.log('loading more user list, status:', status) 39 | } 40 | }, [status, users, getUsers]), 500) 41 | if (status === 'loading' && !users.length) 42 | return 43 | return (<> 44 | 45 | {users && users.length ? users.slice(0, length).map(user => { 46 | return ( 54 | 55 | 62 | 63 | 64 | 65 |

{user.name}

66 |

@{user.screen_name}

67 | 68 | 69 | 74 | 75 |
76 | 77 | {!compact &&
{truncateText(user.description, 5)}
} 78 |
79 |
80 |
81 |
) 82 | }) : (status === 'idle' && 83 |
No users to show
84 | )} 85 | {status === 'loading' ? : null} 86 | {status === 'error' && } 87 |
88 | ) 89 | } -------------------------------------------------------------------------------- /src/comps/chat-room-placeholder.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Heading from 'comps/Heading' 4 | 5 | export default props => { 6 | return (<> 7 | 8 |

9 | This feature i want badly, but also have no idea where to head, so i need your help on that

10 | New unique features would help this project stand out and not be just "one more twitter clone"

11 | But also if nobody is interested is this, i will also just move on 12 |

13 | ) 14 | } -------------------------------------------------------------------------------- /src/comps/post-tag.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import UserLink from 'comps/user-link' 3 | import { Link } from 'react-router-dom' 4 | import { useSelector } from 'react-redux' 5 | 6 | export default ({ post, no_reply_tag = false }) => { 7 | const { user: authUser } = useSelector(state => state.auth) 8 | let { retweeted_by } = post 9 | 10 | let name1 = authUser && (authUser.screen_name === post.user.screen_name) ? 'You' : '@' + post.user.screen_name 11 | let name2 = authUser && (authUser.screen_name === post.in_reply_to_screen_name) ? 'you' : '@' + post.in_reply_to_screen_name 12 | let reply_tag_text = `${name1} replied to ${name2}` 13 | return <> 14 | {retweeted_by && (no_reply_tag = true) && ( 15 | 20 | @{retweeted_by.screen_name} retweeted 21 | 22 | )} 23 | {!no_reply_tag && post.in_reply_to_screen_name && ( 24 | 28 | {reply_tag_text} 29 | 30 | )} 31 | 32 | } -------------------------------------------------------------------------------- /src/comps/prompt-modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useState, useEffect } from 'react' 3 | import { Modal } from 'react-bootstrap' 4 | 5 | 6 | export default ( 7 | { header, body, confirmText, cancelText, handleConfirm, handleCancel, delay, ...props } 8 | ) => { 9 | let [defaultShow, setShow] = useState(false) 10 | 11 | let onHide = () => { 12 | setShow(false); 13 | handleCancel && handleCancel(); 14 | } 15 | let onConfirm = () => { 16 | handleConfirm && handleConfirm(); 17 | setShow(false) 18 | props.onHide ? props.onHide() : handleCancel && handleCancel() 19 | } 20 | useEffect(() => { 21 | setTimeout(() => { 22 | setShow(true) 23 | }, delay || 250) 24 | // eslint-disable-next-line 25 | }, []) 26 | return (<> 27 | 35 | 36 |

{header || "Confirm"}

37 |

{body || "Please confirm you action to proceed or click anywhere outside or Esc or button below to cancel"}

38 |
39 | 43 | 47 |
48 |
49 |
50 | ) 51 | } -------------------------------------------------------------------------------- /src/comps/quoted-post.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactTimeAgo from 'react-time-ago' 3 | import { Link } from 'react-router-dom' 4 | import { Row, Card, Figure } from 'react-bootstrap' 5 | import MultiMedia from 'comps/MultiMedia' 6 | import PostText from 'comps/PostText' 7 | import UserLink from 'comps/user-link' 8 | 9 | export default ({ post, className, expanded = false }) => { 10 | if (!post) 11 | return <> 12 | return (<> 13 | 14 | 15 |
16 | 17 | 22 |
26 | 30 |
31 |
32 | {post.user.name} 37 | {/* tick */} 38 | @{post.user.screen_name} 39 |
{" - "}
40 | 41 |
42 |
43 | 44 |
45 | 46 |
47 | 48 | 52 | 53 |
54 | ) 55 | } -------------------------------------------------------------------------------- /src/comps/splash.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default props => { 4 | return (
5 | logo 11 |
) 12 | } -------------------------------------------------------------------------------- /src/comps/user-link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { OverlayTrigger, Popover, Card, Figure, Row, } from 'react-bootstrap' 4 | import { numFormatter } from 'utils/helpers' 5 | import FollowButton from 'comps/FollowButton' 6 | import { useDispatch } from 'react-redux' 7 | import { followUser, unFollowUser } from '../features/users/usersSlice' 8 | 9 | import { truncateText } from 'utils/helpers' 10 | export default ({ user, ...props }) => { 11 | // A dirty hack to force showing of popover when popover itself is hovered 12 | let [show, setShow] = React.useState(undefined) 13 | if (!props.hasOwnProperty('to')) 14 | props.to = `/user/${user.screen_name}` 15 | return ( 16 | } 18 | > 19 | 20 | 21 | ) 22 | } 23 | export const UserPopover = React.forwardRef( 24 | ({ popper, user, show, setShow, ...props }, ref) => { 25 | let dispatch = useDispatch() 26 | return ( 31 | { setShow(true) }} 33 | onMouseLeave={() => { setShow(undefined) }} 34 | className="border p-3 bg-transparent m-0"> 35 | 36 |
40 | 44 |
45 | { dispatch(followUser(user.screen_name)) }} 48 | unFollowUser={() => { dispatch(unFollowUser(user.screen_name)) }} 49 | /> 50 |
51 |
52 | {user.name} 53 |
{user.screen_name}
54 |
55 |
{truncateText(user.description, 7)}
56 | 57 | {user.location} 58 | Joined {new Date(user.created_at).toDateString()} 59 | 60 | 61 | {numFormatter(user.followers_count)} Followers 62 |
{numFormatter(user.friends_count)} Following
63 |
64 |
65 |
) 66 | } 67 | ) -------------------------------------------------------------------------------- /src/comps/with-urls.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { unescape } from 'html-escaper'; 3 | import anchorme from "anchorme"; 4 | import DOMPurify from 'dompurify'; 5 | 6 | export default ({ children }) => { 7 | if (!children || !children.toString) 8 | return null 9 | let text = children.toString() //can be escaped too 10 | text = DOMPurify.sanitize(unescape(text), { ALLOWED_TAGS: ['b'] }); 11 | text = anchorme({ 12 | input: text, 13 | // use some options 14 | options: { 15 | attributes: { 16 | target: "_blank", 17 | rel: "noopener noreferrer", 18 | class: "text-wrap break-all", 19 | }, 20 | // any link above 50 characters will be truncated 21 | truncate: 50, 22 | }, 23 | // and extensions 24 | extensions: [ 25 | // an extension for hashtag search 26 | { 27 | test: /#(\w|_)+/gi, 28 | transform: (string) => 29 | ` ${string} `, 30 | }, 31 | // an extension for mentions 32 | { 33 | test: /@(\w|_)+/gi, 34 | transform: (string) => 35 | `${string}`, 36 | }, 37 | ], 38 | }); 39 | 40 | // should not pass for a good code beforehand 41 | if (DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }).trim() === '') 42 | text = 'null' 43 | return (<> 44 |
45 | ) 46 | } -------------------------------------------------------------------------------- /src/features/alerts/alertsContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useState, useCallback } from 'react' 3 | 4 | import Prompt from 'comps/prompt-modal' 5 | 6 | import { useSelector } from 'react-redux' 7 | import { askPermission, subscribeUserToPush } from '../../subscription' 8 | import { useHistory } from 'react-router-dom' 9 | 10 | const AlertsContext = React.createContext() 11 | const AlertsProvider = ({ children, ...props }) => { 12 | const history = useHistory() 13 | 14 | /*Detect incomplete profile on basis of description */ 15 | const { user: { description } } = useSelector(state => state.auth) 16 | 17 | let [showNotifPermission, setNotifPermission] = useState(false) 18 | 19 | // checks if there are already modals active 20 | const isAnyModal = useCallback(() => { 21 | if (showNotifPermission || 22 | history.location.pathname === '/settings/profile' || 23 | history.location.pathname === '/compose/post' 24 | ) //similarly for others 25 | return true 26 | return false 27 | }, [showNotifPermission, history.location.pathname]) 28 | const handlePermission = (result) => { 29 | console.log('Permission result: ', result) 30 | if (result === true) 31 | subscribeUserToPush() 32 | } 33 | 34 | // kinda apis for this provider 35 | const ensureNotifPermission = () => { 36 | const delay = 3000 37 | setTimeout(() => { 38 | /*May be dont ask if it denied already, but lets just keep it for now (¬‿¬)*/ 39 | const isNotificationPermitted = Notification.permission === 'granted' 40 | if (!isAnyModal() && !isNotificationPermitted) 41 | setNotifPermission(true) 42 | }, delay) 43 | } 44 | const ensureCompleteProfile = useCallback(() => { 45 | const delay = 500 46 | setTimeout(() => { 47 | if (!isAnyModal() && !description) 48 | history.push('/settings/profile?redirected=true') 49 | }, delay); 50 | }, [description, history, isAnyModal]) 51 | return ( 55 | {children} 56 | {/* notification prompt */} 57 | askPermission().then(handlePermission)} 64 | handleCancel={() => { setNotifPermission(false) }} 65 | /> 66 | ) 67 | } 68 | 69 | const useAlerts = () => React.useContext(AlertsContext) 70 | //for functional components 71 | 72 | export { AlertsProvider, useAlerts, AlertsContext } -------------------------------------------------------------------------------- /src/features/notify/notify-page.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useEffect } from 'react' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import { notifySelectors, readNotif } from './notifySlice' 5 | import { useAlerts } from 'features/alerts/alertsContext' 6 | import { fetchNotifs } from './notifySlice' 7 | 8 | import { Link } from 'react-router-dom' 9 | import { ListGroup, Figure } from 'react-bootstrap' 10 | import QuotePost from 'comps/quoted-post' 11 | import Heading from 'comps/Heading' 12 | // import UserLink from 'comps/user-link' 13 | import PostText from 'comps/PostText' 14 | 15 | export default props => { 16 | const dispatch = useDispatch() 17 | const notifications = useSelector(notifySelectors.selectAll) 18 | const { user: authUser } = useSelector(state => state.auth) 19 | const { ensureNotifPermission } = useAlerts() 20 | 21 | useEffect(() => { 22 | dispatch(fetchNotifs()) 23 | ensureNotifPermission() 24 | // eslint-disable-next-line 25 | }, []) 26 | 27 | const handleClick = n => { 28 | if (!n.read) dispatch(readNotif(n)) 29 | } 30 | 31 | const readAll = useCallback( 32 | notifications => { 33 | notifications.forEach(n => { 34 | if (!n.read) dispatch(readNotif(n)) 35 | }) 36 | }, 37 | [dispatch] 38 | ) 39 | 40 | useEffect(() => { 41 | readAll(notifications) 42 | }, [notifications, readAll]) 43 | 44 | return ( 45 | <> 46 | 47 | 48 | {notifications.length ? ( 49 | notifications.map(n => { 50 | let active = n.read ? '' : 'bg-bg-color border-left-right-primary-custom' 51 | let post = n.body.post 52 | let user = n.body.user || { screen_name: "" } 53 | let body, 54 | heading, 55 | anchor = '', 56 | tag = n.title 57 | switch (n.type) { 58 | case 'mentioned': 59 | anchor = `/post/${post.id_str}` 60 | body = ( 61 |
62 |

63 | @{user.screen_name} mentioned you in post 64 |

65 |
66 | 67 |
68 |
69 | ) 70 | break 71 | case 'replied': 72 | anchor = `/post/${post.id_str}` 73 | body = ( 74 |
75 |

76 | @{user.screen_name} replied 77 |

78 | 79 |
80 | ) 81 | break 82 | case 'liked': 83 | anchor = `/post/${post.id_str}/likes` 84 | body = ( 85 |
86 |

87 | @{user.screen_name} liked 88 |

89 | 90 |
91 | ) 92 | break 93 | case 'followed': 94 | anchor = `/user/${authUser.screen_name}/followers` 95 | body = ( 96 |
97 |

98 | @{user.screen_name} started following you 99 |

100 |
101 | ) 102 | break 103 | case 'unfollowed': 104 | anchor = `/user/${user.screen_name}` 105 | body = ( 106 |
107 |

108 | @{user.screen_name} no longer follows you 109 |

110 |
111 | ) 112 | break 113 | case 'reposted': 114 | anchor = `/post/${post.id_str}/reposts` 115 | body = ( 116 |
117 |

118 | @{user.screen_name} reposted 119 |

120 | 121 |
122 | ) 123 | break 124 | default: 125 | anchor = '/notifications' 126 | } 127 | if (user) { 128 | heading = ( 129 |
130 | 131 |
135 | 143 |
144 | 145 |
146 | ) 147 | } 148 | return ( 149 | handleClick(n)} 155 | > 156 | 157 |
158 | {tag} 159 |
160 | {heading} 161 | {body} 162 |
163 | ) 164 | }) 165 | ) : ( 166 |
No notifications yet
167 | )} 168 |
169 | 170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /src/features/notify/notifySlice.js: -------------------------------------------------------------------------------- 1 | import { 2 | createAsyncThunk, 3 | createSlice, 4 | createEntityAdapter, 5 | createSelector, 6 | } from '@reduxjs/toolkit' 7 | // import io from 'socket.io-client' 8 | import { request } from 'api' 9 | 10 | const notifyAdapter = createEntityAdapter({ 11 | selectId: notification => notification._id.toString(), 12 | sortComparer: (a, b) => b.created_at.localeCompare(a.created_at), 13 | }) 14 | 15 | // /* TODO move to its own file for general purpose */ 16 | // const socket = io('/auth', { 17 | // autoConnect: false, 18 | // /* 19 | // * As netlify doesn't support socket proxying 20 | // * resorting to just polling, instead of using JWT now 21 | // */ 22 | // transports: ['polling'] 23 | // }); 24 | 25 | // export const initSocket = createAsyncThunk( 26 | // 'notify/initSocket', 27 | // async (_, { dispatch }) => { 28 | // socket.on('connect', () => { 29 | // // console.log("Socket connected? ", socket.connected, ' id: ', socket.id); // true 30 | // }); 31 | 32 | // socket.on('disconnect', () => { 33 | // // console.log("Socket connected? ", socket.connected, ' id: ', socket.id); // false 34 | // }); 35 | // socket.on('error', err => { 36 | // // console.log('Socket error: ', err) 37 | // }) 38 | 39 | // socket.on('notification', notification => dispatch(notificationAdded(notification))) 40 | // socket.on('notifications', notifications => dispatch(notificationsAdded(notifications))) 41 | // socket.on('message', message => console.log('got message: ', message)) 42 | // // socket.open() 43 | // } 44 | // ) 45 | const interval = 15 * 1000 46 | var notifsInterval 47 | export const fetchNotifs = () => async (dispatch, getState) => { 48 | const { 49 | auth: { isAuthenticated }, 50 | } = getState() 51 | if (isAuthenticated && !notifsInterval) { 52 | notifsInterval = setInterval(() => { 53 | dispatch(fetchNotifs()) 54 | }, interval) 55 | } else if (!isAuthenticated && notifsInterval) clearInterval(notifsInterval) 56 | const { 57 | notify: { status }, 58 | } = getState() 59 | if (status === 'loading') return 60 | dispatch(_fetchNotifs()) 61 | } 62 | 63 | export const _fetchNotifs = createAsyncThunk('notifs/fetchAll', async (_, { dispatch }) => { 64 | let { notifications } = await request('/api/notifications', { dispatch }) 65 | if (!notifications) throw Error('No notifications') 66 | return dispatch(notificationsAdded(notifications)) 67 | }) 68 | export const readNotif = createAsyncThunk( 69 | 'notifs/readNotif', 70 | async (notification, { dispatch }) => { 71 | dispatch(notifRead(notification)) 72 | return request(`/api/notification_read/${notification._id}`, { dispatch, body: {} }) 73 | } 74 | ) 75 | const initialState = notifyAdapter.getInitialState({ 76 | status: 'idle', // || 'loading' 77 | }) 78 | const notifySlice = createSlice({ 79 | name: 'notify', 80 | initialState, 81 | reducers: { 82 | notificationAdded: notifyAdapter.upsertOne, 83 | notificationsAdded: notifyAdapter.upsertMany, 84 | notifRead: (state, action) => { 85 | let notif = action.payload 86 | notifyAdapter.upsertOne(state, { 87 | ...notif, 88 | read: true, 89 | }) 90 | }, 91 | }, 92 | extraReducers: { 93 | [fetchNotifs.pending]: state => { 94 | state.status = 'loading' 95 | }, 96 | [fetchNotifs.rejected]: state => { 97 | state.status = 'idle' 98 | }, 99 | [fetchNotifs.fulfilled]: state => { 100 | state.status = 'idle' 101 | }, 102 | }, 103 | }) 104 | 105 | const { reducer, actions } = notifySlice 106 | export const { notificationAdded, notificationsAdded, notifRead } = actions 107 | export default reducer 108 | 109 | export const notifySelectors = notifyAdapter.getSelectors(state => state.notify) 110 | 111 | export const selectUnread = createSelector([notifySelectors.selectAll], all => 112 | all.filter(one => !one.read) 113 | ) 114 | -------------------------------------------------------------------------------- /src/features/posts/Compose.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faImage } from '@fortawesome/free-regular-svg-icons/faImage' 5 | import { faSmile } from '@fortawesome/free-regular-svg-icons/faSmile' 6 | 7 | import { connect } from 'react-redux' 8 | import { composePost } from 'features/posts/postsSlice' 9 | import { withRouter, Link } from 'react-router-dom' 10 | 11 | import { Media } from 'react-bootstrap' 12 | import { AlertsContext } from 'features/alerts/alertsContext' 13 | 14 | import DOMPurify from 'dompurify' 15 | import { filterInput } from 'utils/helpers' 16 | 17 | class Compose extends React.Component { 18 | static contextType = AlertsContext 19 | state = { 20 | editor_text: '', 21 | active: false, 22 | pending: false, 23 | } 24 | handleChange(e) { 25 | let ta = e.target 26 | if (!this.ta) this.ta = ta 27 | let text = ta.value 28 | this.setState({ 29 | editor_text: text, 30 | active: this.isValid(text), 31 | }) 32 | this.resizeTa() 33 | } 34 | isValid(text = '') { 35 | return Boolean(DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }).trim().length > 0) 36 | } 37 | // eslint-disable-next-line no-dupe-class-members 38 | handleChange = this.handleChange.bind(this) 39 | handleSubmit = async e => { 40 | if (!this.state.active) return 41 | let text = this.state.editor_text.trim() 42 | try { 43 | text = filterInput(this.state.editor_text, 'html', { 44 | max_length: 500, 45 | identifier: 'Post', 46 | }) 47 | } catch (err) { 48 | return alert(err.message) 49 | } 50 | this.setState({ active: false }) 51 | let body = { 52 | text, 53 | } 54 | await this.props.composePost({ body }) 55 | this.setState({ editor_text: '' }) 56 | this.resizeTa() 57 | let { 58 | posts: { compose_status }, 59 | } = this.props 60 | if (compose_status === 'error') { 61 | alert('Post could not be submitted, try again') 62 | } else this.context.ensureNotifPermission() 63 | } 64 | resizeTa() { 65 | // for auto resizing of text area 66 | if (this.ta) { 67 | this.ta.style.height = 'auto' 68 | this.ta.style.height = this.ta.scrollHeight + 'px' 69 | } 70 | } 71 | render() { 72 | let { auth, className } = this.props 73 | let { user } = auth 74 | return ( 75 |
76 | 77 | 78 | 89 | 90 | 91 | 100 |
101 |
102 | 106 | 107 | 108 | 111 |
112 |
113 | 120 |
121 |
122 |
123 |
124 |
125 | ) 126 | } 127 | } 128 | export default connect(state => state, { composePost })(withRouter(Compose)) 129 | -------------------------------------------------------------------------------- /src/features/posts/Feed.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | 4 | // import ScrollManager from 'comps/ScrollManager' 5 | import { useDispatch, useSelector } from 'react-redux' 6 | import { getFeed, selectFeedPosts } from './postsSlice' 7 | 8 | import FollowCard from 'layouts/main/sidebar/FollowCard' 9 | import PostsList from 'comps/PostsList' 10 | 11 | export default (props) => { 12 | let { feed_status: status } = useSelector(state => state.posts) 13 | let posts = useSelector(selectFeedPosts) 14 | let dispatch = useDispatch() 15 | const getPosts = useCallback(() => { 16 | dispatch(getFeed()) 17 | // eslint-disable-next-line 18 | }, []) 19 | // if (status === 'error') 20 | // append = 21 | let append; 22 | if (status === 'done') 23 | append = (<> 24 |
You have reached the end!
25 | 26 | ) 27 | return (<> 28 | {/* */} 29 | 34 | {append} 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/features/posts/PostDetail.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect, useCallback } from 'react' 3 | import Heading from 'comps/Heading' 4 | import { Link } from 'react-router-dom' 5 | import { Row, Col, Figure } from 'react-bootstrap' 6 | import MultiMedia from 'comps/MultiMedia' 7 | import { useSelector, useDispatch } from 'react-redux' 8 | import { selectPostById, getPost, selectReplies, getReplies } from './postsSlice' 9 | 10 | import { numFormatter } from 'utils/helpers' 11 | import ScrollToTop from 'comps/ScrollToTop' 12 | import ReactionsBar from './ReactionsBar' 13 | import PostText from 'comps/PostText' 14 | import QuotedPost from 'comps/quoted-post' 15 | import UserLink from 'comps/user-link' 16 | import Spinner from 'comps/Spinner' 17 | import PostsList from 'comps/PostsList' 18 | import PostTag from 'comps/post-tag' 19 | 20 | export default props => { 21 | let { match: { params: { postId } = {} } = {} } = props 22 | let dispatch = useDispatch() 23 | let post = useSelector(state => selectPostById(state, postId)) 24 | const replies = useSelector(state => selectReplies(state, postId)) 25 | let { post_detail_status: status, post_replies_status } = useSelector(state => state.posts) 26 | useEffect(() => { 27 | if (!post) 28 | dispatch(getPost(postId)) 29 | }, [post, postId, dispatch]) 30 | const getPosts = useCallback(() => { 31 | dispatch(getReplies(postId)) 32 | }, [dispatch, postId]) 33 | 34 | if (status === 'loading') 35 | return 36 | if (!post) { 37 | return
Post not Found
38 | } 39 | return (<> 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 53 |
57 | 61 |
62 |
63 | 64 | 68 | {post.user.name} 69 | 70 | {/* tick */} 71 | @{post.user.screen_name} 72 | 73 |
74 | 75 |
76 |
79 | 80 |
81 | 82 | 86 | 87 | 88 | 89 | 90 | {new Date(post.created_at).toLocaleTimeString()} 91 | {" - "} 92 | {new Date(post.created_at).toDateString()} 93 | 94 | 95 | 96 |
97 | {numFormatter(post.favorite_count)} 98 | Likes 99 |
100 |
101 | {numFormatter(post.retweet_count)} 102 | Reposts 103 |
104 |
105 | 106 | 107 | 108 | 114 | 115 | ) 116 | } -------------------------------------------------------------------------------- /src/features/posts/ReactionsBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch } from 'react-redux' 3 | import { likePost, unlikePost, repostPost, unRepostPost } from './postsSlice' 4 | import { Dropdown } from 'react-bootstrap' 5 | import { Link } from 'react-router-dom' 6 | import { useSelector } from 'react-redux' 7 | import { useHistory } from 'react-router-dom' 8 | 9 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 10 | import { faComment } from '@fortawesome/free-regular-svg-icons/faComment' 11 | import { faComment as commentSolid } from '@fortawesome/free-solid-svg-icons/faComment' 12 | import { faHeart } from '@fortawesome/free-regular-svg-icons/faHeart' 13 | import { faHeart as heartSolid } from '@fortawesome/free-solid-svg-icons/faHeart' 14 | import { faReply } from '@fortawesome/free-solid-svg-icons/faReply' 15 | import { numFormatter } from 'utils/helpers' 16 | 17 | export default props => { 18 | const history = useHistory() 19 | 20 | let { isAuthenticated } = useSelector(state => state.auth) 21 | let dispatch = useDispatch() 22 | let handleLike = e => { 23 | e.preventDefault() 24 | if (!isAuthenticated) { 25 | history.push(`/login`) 26 | return 27 | } 28 | post.favorited ? dispatch(unlikePost(post)) : dispatch(likePost(post)) 29 | 30 | } 31 | let handleRepost = post => { 32 | if (!isAuthenticated) { 33 | history.push(`/login`) 34 | return 35 | } 36 | post.retweeted ? dispatch(unRepostPost(post)) : dispatch(repostPost(post)) 37 | } 38 | let { post } = props 39 | return (
40 | 41 | 45 | {post.retweeted ? ( 46 | 47 | ) : } 48 | {numFormatter(post.retweet_count)} 49 | 50 | 51 | handleRepost(post)} 55 | >{post.retweeted ? "Undo Repost" : "Repost"} 56 | Quote this post 61 | 62 | 63 | {/* reply */} 64 | 68 | 69 | 70 | {/* like */} 71 | 81 |
) 82 | } -------------------------------------------------------------------------------- /src/features/posts/compose-modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useState, useRef } from 'react' 3 | import { Modal, Media, Alert, ProgressBar, Popover, OverlayTrigger } from 'react-bootstrap' 4 | import { useHistory, useLocation } from 'react-router-dom' 5 | 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 7 | import { faImage } from '@fortawesome/free-regular-svg-icons/faImage' 8 | import { faSmile } from '@fortawesome/free-regular-svg-icons/faSmile' 9 | 10 | import { useSelector, useDispatch } from 'react-redux' 11 | import { composePost, selectPostById } from './postsSlice' 12 | import { useEffect } from 'react' 13 | 14 | import QuotedPost from 'comps/quoted-post' 15 | 16 | import 'emoji-mart/css/emoji-mart.css' 17 | import { Picker } from 'emoji-mart' 18 | import DOMPurify from 'dompurify'; 19 | import { filterInput } from 'utils/helpers' 20 | 21 | export default props => { 22 | let location = useLocation() 23 | let history = useHistory() 24 | let dispatch = useDispatch() 25 | 26 | let { user } = useSelector(state => state.auth); 27 | let quoteId = new URLSearchParams(location.search).get("quote"); 28 | let quotePost = useSelector(state => selectPostById(state, quoteId)); 29 | 30 | const replyId = new URLSearchParams(location.search).get("reply_to"); 31 | let replyPost = useSelector(state => selectPostById(state, replyId)); 32 | 33 | let { compose_status: status } = useSelector(state => state.posts) 34 | 35 | let ta = useRef(null) 36 | const [height, setHeight] = useState("auto") 37 | const [editor_text, setText] = useState(``) 38 | const [active, setActive] = useState(false) 39 | 40 | const [error, setError] = useState(null) 41 | 42 | let [progress, setProgress] = useState(10) 43 | 44 | let dirtyProgress = () => { 45 | if (progress < 90) 46 | setTimeout(() => { setProgress(90) }, 200) 47 | return true 48 | } 49 | const handleClose = () => { 50 | if (status !== 'error' || true) { 51 | history.goBack(); 52 | } 53 | } 54 | let resizeTa = () => { 55 | if (ta.current) { 56 | // let height = ta.current.scrollHeight; 57 | // cur.height = 'auto'; 58 | // cur.height = (cur.scrollHeight) + 'px'; 59 | setHeight('auto') 60 | } 61 | } 62 | useEffect(() => { 63 | if (ta.current) { 64 | let height = ta.current.scrollHeight; 65 | setHeight(height + 'px') 66 | } 67 | }, [editor_text]) 68 | useEffect(() => { 69 | if (ta.current) 70 | ta.current.focus() 71 | }, []) 72 | let handleChange = e => { 73 | resizeTa() 74 | let text = e.target.value 75 | setText(text) 76 | setActive(DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }).trim().length > 0) 77 | } 78 | let handleSubmit = async (e) => { 79 | if (!active) 80 | return; 81 | let text; 82 | try { 83 | text = filterInput(editor_text, 'html', { max_length: 500, identifier: 'Post' }) 84 | } catch (err) { 85 | return setError(err.message) 86 | } 87 | setActive(false) 88 | let body = { 89 | text 90 | } 91 | let url; 92 | if (replyId) { 93 | url = `/api/post/${replyId}/reply` 94 | } 95 | else if (quotePost) { 96 | body = { 97 | ...body, 98 | is_quote_status: true, 99 | quoted_status_id: quotePost.id, 100 | quoted_status_id_str: quotePost.id_str, 101 | quoted_status: quotePost._id 102 | } 103 | } 104 | let action = await dispatch(composePost({ body, url })) 105 | setActive(true) 106 | if (action.type === 'posts/composePost/fulfilled') 107 | handleClose() 108 | } 109 | let addEmoji = emoji => { 110 | setText(text => (text + emoji.native)) 111 | } 112 | const picker = ( 113 | 114 | 122 | 123 | ); 124 | 125 | return ( 126 | <> 127 | 136 | 137 | 138 | {replyId ? 'Post your reply' : 'Compose post'} 139 | 140 | 141 | {status === 'pending' && ( 142 | dirtyProgress() && 143 | 144 | )} 145 | {status === "error" && ( 146 | 147 | Error submiting post, try again! 148 | 149 | )} 150 | {error && ( 151 | 152 | {error} 153 | 154 | )} 155 | 156 | 157 | 164 | 165 | 177 | 178 | 179 | 180 | 181 | 182 |
183 |
184 | 185 | 188 | 189 | 192 |
193 |
194 | 200 |
201 |
202 |
203 |
204 | 205 | ); 206 | } -------------------------------------------------------------------------------- /src/features/posts/postsSlice.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createAsyncThunk, 4 | createEntityAdapter, 5 | createSelector, 6 | } from '@reduxjs/toolkit' 7 | import { request } from 'api' 8 | 9 | import { parsePosts, populatePost } from './utils' 10 | 11 | const postsAdapter = createEntityAdapter({ 12 | selectId: post => post.id_str, 13 | sortComparer: (a, b) => b.created_at.localeCompare(a.created_at), 14 | }) 15 | const initialState = postsAdapter.getInitialState({ 16 | feed_status: 'idle', // || 'loading', 'error', 'done' 17 | feed_page: 0, //page currently on, page to fetch is next one 18 | compose_status: 'idle', // || 'pending', 'error', 19 | post_detail_status: 'idle', 20 | 21 | post_replies_status: 'idle', 22 | post_replies_page: 0, 23 | }) 24 | 25 | export const getPost = createAsyncThunk('posts/getPost', async (postId, { dispatch }) => { 26 | let { post } = await request(`/api/post/${postId}`, { dispatch }) 27 | if (!post) throw Error('Post not available') 28 | return dispatch(parsePosts([post])) 29 | }) 30 | 31 | export const getFeed = createAsyncThunk('posts/getFeed', async (_, { dispatch, getState }) => { 32 | try { 33 | let { 34 | posts: { feed_page: p }, 35 | } = getState() 36 | let url = `/api/home_timeline?p=${p + 1}` 37 | let data = await request(url, { dispatch }) 38 | let posts = data.posts || [] 39 | posts = posts.filter(Boolean).map(post => ({ ...post, is_feed_post: true })) 40 | dispatch(parsePosts(posts)) 41 | return posts.length 42 | } catch (err) { 43 | console.log(err) 44 | throw err 45 | } 46 | }) 47 | export const getReplies = createAsyncThunk( 48 | 'posts/getReplies', 49 | async (postId, { dispatch, getState }) => { 50 | let { 51 | posts: { post_replies_page: p }, 52 | } = getState() 53 | p = parseInt(p) 54 | let l = selectReplies(getState(), postId).length 55 | if (!l) { 56 | dispatch(resetRepliesPage()) 57 | p = 0 58 | } 59 | let { posts } = await request(`/api/post/${postId}/replies?p=${p + 1}`, { dispatch }) 60 | posts = posts || [] //fix, only undefined gets default value in destructing 61 | if (!posts.length) return 62 | // posts = posts.map(post => ({ ...post, reply_to: postId })) 63 | dispatch(parsePosts(posts)) 64 | return posts.length 65 | } 66 | ) 67 | 68 | export const likePost = createAsyncThunk('posts/likePost', async (post, { dispatch }) => { 69 | dispatch(postLiked(post)) 70 | await request(`/api/like/${post.id_str}`, { dispatch, body: {} }) 71 | }) 72 | export const unlikePost = createAsyncThunk('posts/unlikePost', async (post, { dispatch }) => { 73 | dispatch(postUnliked(post)) 74 | return request(`/api/unlike/${post.id_str}`, { dispatch, body: {} }) 75 | }) 76 | export const repostPost = createAsyncThunk('posts/repostPost', async (post, { dispatch }) => { 77 | dispatch(postReposted(post)) 78 | return request(`/api/repost`, { body: post, dispatch }) 79 | }) 80 | export const unRepostPost = createAsyncThunk('posts/unRepostPost', async (post, { dispatch }) => { 81 | dispatch(postUnReposted(post)) 82 | return request(`/api/unrepost`, { body: post, dispatch }) 83 | }) 84 | 85 | export const composePost = createAsyncThunk( 86 | 'posts/composePost', 87 | async ({ body, url = '/api/post' }, { dispatch }) => { 88 | try { 89 | let { post } = await request(url, { body, dispatch }) 90 | if (post) post.user.following = true //work around till server shows this correctly on all posts/users 91 | return dispatch(parsePosts([post])) 92 | } catch (err) { 93 | console.log(err) 94 | throw err 95 | } 96 | } 97 | ) 98 | 99 | const postsSlice = createSlice({ 100 | name: 'posts', 101 | initialState, 102 | reducers: { 103 | postsAdded: postsAdapter.upsertMany, 104 | postAdded: postsAdapter.upsertOne, 105 | postLiked: (state, action) => { 106 | let post = action.payload 107 | postsAdapter.updateOne(state, { 108 | id: post.id_str, 109 | changes: { 110 | favorited: true, 111 | favorite_count: post.favorite_count + 1, 112 | }, 113 | }) 114 | }, 115 | postUnliked: (state, action) => { 116 | let post = action.payload 117 | postsAdapter.updateOne(state, { 118 | id: post.id_str, 119 | changes: { 120 | favorited: false, 121 | favorite_count: post.favorite_count - 1, 122 | }, 123 | }) 124 | }, 125 | postReposted: (state, action) => { 126 | let post = action.payload 127 | postsAdapter.updateOne(state, { 128 | id: post.id_str, 129 | changes: { 130 | retweet_count: post.retweet_count + 1, 131 | retweeted: true, 132 | }, 133 | }) 134 | }, 135 | postUnReposted: (state, action) => { 136 | let post = action.payload 137 | postsAdapter.updateOne(state, { 138 | id: post.id_str, 139 | changes: { 140 | retweet_count: post.retweet_count - 1, 141 | retweeted: false, 142 | }, 143 | }) 144 | }, 145 | resetRepliesPage: state => { 146 | state.post_replies_page = 0 147 | }, 148 | }, 149 | extraReducers: { 150 | [getFeed.rejected]: state => { 151 | state.feed_status = 'error' 152 | }, 153 | [getFeed.pending]: state => { 154 | state.feed_status = 'loading' 155 | }, 156 | [getFeed.fulfilled]: (state, action) => { 157 | let length = action.payload 158 | if (length > 0) { 159 | state.feed_status = 'idle' 160 | state.feed_page += 1 161 | } else state.feed_status = 'done' 162 | }, 163 | [composePost.pending]: state => { 164 | state.compose_status = 'pending' 165 | }, 166 | [composePost.rejected]: state => { 167 | state.compose_status = 'error' 168 | }, 169 | [composePost.fulfilled]: state => { 170 | state.compose_status = 'idle' 171 | }, 172 | [getPost.pending]: state => { 173 | state.post_detail_status = 'loading' 174 | }, 175 | [getPost.fulfilled]: state => { 176 | state.post_detail_status = 'idle' 177 | }, 178 | [getPost.rejected]: state => { 179 | state.post_detail_status = 'error' 180 | }, 181 | [getReplies.rejected]: state => { 182 | state.post_replies_status = 'error' 183 | }, 184 | [getReplies.pending]: state => { 185 | state.post_replies_status = 'loading' 186 | }, 187 | [getReplies.fulfilled]: (state, action) => { 188 | let length = action.payload 189 | if (length > 0) { 190 | state.post_replies_status = 'idle' 191 | state.post_replies_page += 1 192 | } else state.post_replies_status = 'done' 193 | }, 194 | }, 195 | }) 196 | const { reducer, actions } = postsSlice 197 | export const { 198 | postsAdded, 199 | postAdded, 200 | postLiked, 201 | postUnliked, 202 | postReposted, 203 | postUnReposted, 204 | resetRepliesPage, 205 | } = actions 206 | export default reducer 207 | 208 | let feedFilter = post => { 209 | return ( 210 | post.user.following === true || 211 | // || (post.user.new) // Can be customizable in settings someday 212 | (post.retweeted_by && post.retweeted_by.following === true) || 213 | post.is_feed_post 214 | ) 215 | } 216 | 217 | export const postsSelectors = postsAdapter.getSelectors(state => state.posts) 218 | 219 | export const selectAllPosts = state => { 220 | return postsSelectors 221 | .selectAll(state) 222 | .map(post => populatePost(post, state)) 223 | .filter(Boolean) 224 | } 225 | 226 | export const selectPostById = createSelector( 227 | [selectAllPosts, (state, postId) => postId], 228 | (posts, postId) => posts.find(post => post.id_str === postId) 229 | ) 230 | export const selectFeedPosts = createSelector([selectAllPosts], posts => posts.filter(feedFilter)) 231 | export const selectUserPosts = createSelector( 232 | [selectAllPosts, (state, username) => username], 233 | (posts, username) => 234 | posts.filter( 235 | post => 236 | post.user.screen_name === username || 237 | (post.retweeted_by && post.retweeted_by.screen_name === username) 238 | ) 239 | ) 240 | export const selectReplies = createSelector( 241 | [selectAllPosts, (state, postId) => postId], 242 | (posts, postId) => posts.filter(post => post.in_reply_to_status_id_str === postId) 243 | ) 244 | 245 | export const selectSearchPosts = createSelector( 246 | [selectAllPosts, (state, query) => query], 247 | (posts, query) => posts.filter(post => post.searched === true && post.query === query) 248 | ) 249 | -------------------------------------------------------------------------------- /src/features/posts/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | usersAdded, 3 | usersAddedDontUpdate, 4 | usersSelectors 5 | } from 'features/users/usersSlice' 6 | 7 | import { postsAdded } from './postsSlice' 8 | 9 | export const populatePost = (post, state) => ({ 10 | ...post, 11 | user: usersSelectors.selectById(state, post.user) || post.backup_user, 12 | retweeted_by: (post.retweeted_by && usersSelectors.selectById(state, post.retweeted_by)) || post.backup_retweeted_by, 13 | quoted_status: (post.quoted_status && populatePost(post.quoted_status, state)) 14 | }) 15 | 16 | export const parsePosts = (posts, { dont_dispatch_posts = false, dont_update_users = false } = {}) => dispatch => { 17 | try { 18 | posts = posts.filter(Boolean) 19 | if (!posts.length) 20 | return 21 | let users = posts.map(post => post.user).filter(Boolean) 22 | let users1 = posts.map(post => post.retweeted_status && post.retweeted_status.user) 23 | .filter(Boolean) 24 | users.push(...users1) 25 | 26 | // extract retweeted status, if any 27 | posts = posts.map(post => { 28 | let { retweeted_status } = post 29 | if (retweeted_status) { 30 | return ({ 31 | ...retweeted_status, 32 | is_feed_post: post.is_feed_post, 33 | is_retweeted_status: true, 34 | retweeted_by: post.user, 35 | created_at: post.created_at 36 | }) 37 | } 38 | return post 39 | }).filter(Boolean) 40 | // replace users with their screen_name (selectId) 41 | posts = posts.map(post => ({ 42 | ...post, 43 | user: post.user.screen_name, 44 | retweeted_by: post.retweeted_by && post.retweeted_by.screen_name, 45 | backup_user: post.user, 46 | backup_retweeted_by: post.retweeted_by 47 | })) 48 | 49 | // parse quoted posts recursively 50 | posts = posts.map(post => { 51 | if (post.quoted_status) { 52 | let [quote] = dispatch(parsePosts([post.quoted_status], { dont_dispatch_posts: true, dont_update_users: true })) 53 | post.quoted_status = quote 54 | } 55 | return post 56 | }) 57 | 58 | if (!dont_dispatch_posts) 59 | dispatch(postsAdded(posts)) 60 | if (dont_update_users) 61 | dispatch(usersAddedDontUpdate(users)) 62 | else 63 | dispatch(usersAdded(users)) 64 | return posts 65 | } catch (err) { 66 | console.log('error parsing', err) 67 | throw err 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/features/search/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PostsList from 'comps/PostsList' 3 | import UsersList from 'comps/UsersList' 4 | import { Redirect, useLocation } from 'react-router-dom' 5 | import { useDispatch, useSelector } from 'react-redux' 6 | import { 7 | changeQuery, 8 | trySearch, 9 | selectSearchPosts, 10 | selectSearchUsers, 11 | } from './searchSlice' 12 | import { followUser, unFollowUser } from 'features/users/usersSlice' 13 | import { useEffect } from 'react' 14 | import Spinner from 'comps/Spinner' 15 | import TryAgain from 'comps/TryAgain' 16 | import Heading from 'comps/Heading' 17 | 18 | export default () => { 19 | let location = useLocation() 20 | let dispatch = useDispatch() 21 | let { search } = location 22 | let { status, query } = useSelector(state => state.search) 23 | let posts = useSelector(state => selectSearchPosts(state, query)) 24 | let users = useSelector(state => selectSearchUsers(state, query)) 25 | let urlq = new URLSearchParams(search).get('q') 26 | if (!urlq || !urlq.trim()) { 27 | return 28 | } 29 | useEffect(() => { 30 | if (query !== urlq) 31 | dispatch(changeQuery(urlq)) 32 | }) 33 | if (status === 'loading' && !(posts.length || users.length)) 34 | return 35 | return (<> 36 | 37 | { dispatch(followUser(username)) }} 40 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 41 | /> 42 | {posts.length ? { dispatch(trySearch()) }} 46 | /> :
No posts to show
} 47 | {status === 'error' && { dispatch(changeQuery(urlq)) }} />} 48 | ) 49 | } -------------------------------------------------------------------------------- /src/features/search/searchSlice.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createAsyncThunk 4 | } from '@reduxjs/toolkit' 5 | import { request } from 'api' 6 | import { usersAdded } from 'features/users/usersSlice' 7 | import { parsePosts } from 'features/posts/utils' 8 | 9 | export const trySearch = () => async (dispatch, getState) => { 10 | let { search: { status } } = getState() 11 | if (status === 'loading') 12 | return 13 | dispatch(getSearch()) 14 | } 15 | export const getSearch = createAsyncThunk( 16 | 'search/getSearch', 17 | async (_, { getState, dispatch }) => { 18 | let { search: { page: p, query: q } } = getState() 19 | if (!q || !q.trim()) 20 | throw Error('No Query') 21 | let query = encodeURIComponent(q) 22 | let url = `/api/search?q=${query}&p=${p + 1}` 23 | let { posts = [], users = [] } = await request(url, { dispatch }) 24 | posts = posts || [] 25 | users = users || [] 26 | posts = posts.map(post => ({ ...post, searched: true, query: q })) 27 | users = users.map(user => ({ ...user, searched: true, query: q })).filter(Boolean) 28 | dispatch(usersAdded(users)) 29 | 30 | dispatch(parsePosts(posts)) 31 | return posts.length 32 | } 33 | ) 34 | export const changeQuery = createAsyncThunk( 35 | 'search/changeQuery', 36 | async (query, { dispatch }) => { 37 | dispatch(queryChanged(query)) 38 | return dispatch(getSearch()) 39 | } 40 | ) 41 | 42 | const searchSlice = createSlice({ 43 | name: 'search', 44 | initialState: { 45 | status: 'idle', // || 'loading', 'error', 'done' 46 | page: 0, //page currently on, page to fetch is next one 47 | query: '', 48 | }, 49 | reducers: { 50 | queryChanged: (state, action) => { 51 | state.query = action.payload 52 | state.page = 0 53 | } 54 | }, 55 | extraReducers: { 56 | [getSearch.rejected]: state => { state.status = 'error' }, 57 | [getSearch.pending]: state => { state.status = 'loading' }, 58 | [getSearch.fulfilled]: (state, action) => { 59 | let length = action.payload 60 | // if (state.page === 0) 61 | // searchAdapter.setAll(state, users.concat(posts)) 62 | // else 63 | // searchAdapter.addMany(state, users.concat(posts)) 64 | if (length) { 65 | state.status = 'idle' 66 | state.page += 1 67 | } 68 | else 69 | state.status = 'done' 70 | } 71 | } 72 | }) 73 | const { actions, reducer } = searchSlice 74 | export default reducer 75 | export const { queryChanged } = actions 76 | 77 | export { selectSearchUsers } from 'features/users/usersSlice' 78 | export { selectSearchPosts } from 'features/posts/postsSlice' -------------------------------------------------------------------------------- /src/features/settings/profile-modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useState } from 'react' 3 | import { 4 | Row, 5 | Modal, 6 | Form, 7 | Figure, 8 | OverlayTrigger, 9 | Popover, 10 | Alert, 11 | ProgressBar, 12 | } from 'react-bootstrap' 13 | import { TwitterPicker } from 'react-color' 14 | import { useHistory } from 'react-router-dom' 15 | import { filterInput } from 'utils/helpers' 16 | 17 | import { useSelector, useDispatch } from 'react-redux' 18 | import { updateUserDetails } from 'features/users/usersSlice' 19 | 20 | export default props => { 21 | let history = useHistory() 22 | let dispatch = useDispatch() 23 | let { user } = useSelector(state => state.auth) 24 | let { user_update_status: status } = useSelector(state => state.users) 25 | 26 | let [color, setColor] = useState(user.profile_banner_color || '#f5f8fa') 27 | let [name, setName] = useState(user.name || '') 28 | let [bio, setBio] = useState(user.description || '') 29 | let [location, setLocation] = useState(user.location || '') 30 | let { entities: { url: { urls: [{ url } = {}] = [] } = {} } = {} } = user 31 | let [website, setWebsite] = useState(url || '') 32 | let [profile, setProfile] = useState(user.profile_image_url_https || getRandomProfileUrl()) 33 | 34 | let [error, setError] = useState(null) 35 | let [progress, setProgress] = useState(10) 36 | 37 | const redirected = new URLSearchParams(history.location.search).get('redirected') 38 | 39 | let dirtyProgress = () => { 40 | if (progress < 90) 41 | setTimeout(() => { 42 | setProgress(90) 43 | }, 250) 44 | return true 45 | } 46 | 47 | const handleClose = () => { 48 | if (status !== 'error' && !error) { 49 | if (redirected === 'true') history.push('/home') 50 | else history.goBack() 51 | } 52 | } 53 | const handleSubmit = async () => { 54 | setError(null) 55 | try { 56 | let _name = filterInput(name, 'name', { identifier: 'Name' }) 57 | let description = filterInput(bio, 'html', { max_length: 200, identifier: 'Bio' }) 58 | let profile_banner_color = filterInput(color, null, { 59 | regex: /^#[0-9A-Fa-f]{3,6}$/, 60 | identifier: 'Banner color', 61 | }) 62 | let _location = filterInput(location, 'name', { min_length: 0, identifier: 'Location' }) 63 | let _website = filterInput(website, 'html', { 64 | min_length: 0, 65 | identifier: 'Website URL', 66 | }) 67 | let profile_image_url_https = profile 68 | let body = { 69 | name: _name, 70 | description, 71 | profile_banner_color, 72 | location: _location, 73 | website: _website, 74 | profile_image_url_https, 75 | } 76 | let action = await dispatch(updateUserDetails(body)) 77 | if (action.type === 'users/updateUserDetails/fulfilled') { 78 | handleClose() 79 | } 80 | } catch (err) { 81 | setError(err.message) 82 | } 83 | } 84 | const picker = ( 85 | 86 | setColor(color.hex)} 100 | triangle="hide" 101 | /> 102 | 103 | ) 104 | return ( 105 | <> 106 | 116 | 117 | 118 | 119 | {!redirected ? 'Edit profile' : 'Complete your profile'}{' '} 120 | 121 | 122 | 123 | {status === 'pending' && dirtyProgress() && ( 124 | 125 | )} 126 | {status === 'error' && ( 127 | 128 | Error updating details, try again! 129 | 130 | )} 131 | {error && ( 132 | 133 | {error} 134 | 135 | )} 136 | 137 |
138 |
e.preventDefault()}> 139 |
143 | {user.profile_banner_url && ( 144 | 148 | )} 149 | 155 | 161 | 162 |
163 |
164 | 165 |
169 | 170 |
171 | 177 |
178 | 179 | Name 180 | setName(n.target.value)} 185 | /> 186 | 187 | 188 | Bio 189 | setBio(n.target.value)} 194 | /> 195 | 196 | 197 | Location 198 | setLocation(n.target.value)} 203 | /> 204 | 205 | 206 | Website 207 | setWebsite(n.target.value)} 212 | /> 213 | 214 |
215 |
216 |
217 |
218 | 219 |
220 |
221 |
222 | 230 |
231 |
232 |
233 |
234 | 235 | ) 236 | } 237 | 238 | // straight from server 239 | function getRandomProfileUrl() { 240 | //geneartes random pic in img 241 | let imgs = [ 242 | 'animals-1298747.svg', 243 | 'bunny-155674.svg', 244 | 'cat-154642.svg', 245 | 'giraffe-2521453.svg', 246 | 'iron-man-3829039.svg', 247 | 'ironman-4454663.svg', 248 | 'lion-2521451.svg', 249 | 'man-1351317.svg', 250 | 'pumpkin-1640465.svg', 251 | 'rat-152162.svg', 252 | 'sherlock-3828991.svg', 253 | 'spider-man-4639214.svg', 254 | 'spiderman-5247581.svg', 255 | 'thor-3831290.svg', 256 | 'tiger-308768.svg', 257 | 'whale-36828.svg', 258 | ] 259 | let img = imgs[Math.floor(Math.random() * imgs.length)] 260 | // TODO A stable and real image server! 261 | return `/img/${img}` 262 | } 263 | -------------------------------------------------------------------------------- /src/features/settings/settings-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Switch, Route } from 'react-router-dom' 3 | 4 | import Profile from './profile-modal' 5 | import Heading from 'comps/Heading' 6 | 7 | export default props => { 8 | return (<> 9 | 10 | 11 | 12 | 13 |
Settings coming in future
14 | ) 15 | } -------------------------------------------------------------------------------- /src/features/trends/Trends.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect } from 'react' 3 | import TryAgain from 'comps/TryAgain' 4 | import Spinner from 'comps/Spinner' 5 | import { Link } from 'react-router-dom' 6 | import { ListGroup } from 'react-bootstrap' 7 | 8 | import { useDispatch, useSelector } from 'react-redux' 9 | import { getTrends, trendsSelectors } from './trendsSlice' 10 | 11 | export default (props) => { 12 | let dispatch = useDispatch() 13 | let { status } = useSelector(state => state.trends) 14 | let trendObj = useSelector(state => trendsSelectors.selectById(state, 1)) 15 | if (trendObj) 16 | var { locations: [location], trends } = trendObj 17 | else 18 | trends = null 19 | useEffect(() => { 20 | if (status === 'idle') 21 | dispatch(getTrends()) 22 | // eslint-disable-next-line 23 | }, []) 24 | if (status === 'loading' && (!trends || !trends.length)) 25 | return 26 | if (status === 'error' && (!trends || !trends.length)) 27 | return { dispatch(getTrends()) }} /> 28 | if (!trends || !trends.length) 29 | return
No Trends for you RN
30 | return ( 31 | 32 | {(trends.slice(0, props.length).map(itm => { 33 | return ( 34 | 40 | Trending {location.name} 41 |

{itm.name}

42 | {itm.tweet_volume + ' Tweets'} 43 |
44 | ) 45 | }))} 46 |
47 | ) 48 | } -------------------------------------------------------------------------------- /src/features/trends/trendsSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit' 2 | import { request } from 'api' 3 | 4 | const trendsAdapter = createEntityAdapter({ 5 | selectId: trend => trend.locations[0].woeid, 6 | sortComparer: (a, b) => (b.trends.length - a.trends.length) 7 | }) 8 | const initialState = trendsAdapter.getInitialState({ 9 | status: 'idle' 10 | }) 11 | 12 | export const getTrends = createAsyncThunk( 13 | 'trends/getTrends', 14 | async (woeid = 1, { dispatch }) => { 15 | let { locations, trends } = await request(`/api/trends?woeid=${woeid}`, { dispatch }); 16 | return { locations, trends } 17 | } 18 | ) 19 | 20 | const trendsSlice = createSlice({ 21 | name: 'trends', 22 | initialState, 23 | reducers: {}, 24 | extraReducers: { 25 | [getTrends.rejected]: state => { state.status = 'error' }, 26 | [getTrends.pending]: state => { state.status = 'loading' }, 27 | [getTrends.fulfilled]: (state, action) => { 28 | state.status = 'idle' 29 | trendsAdapter.upsertOne(state, action.payload) 30 | } 31 | } 32 | }) 33 | const { actions, reducer } = trendsSlice 34 | export default reducer 35 | export const { queryChanged } = actions 36 | 37 | export const trendsSelectors = trendsAdapter.getSelectors(state => state.trends) 38 | -------------------------------------------------------------------------------- /src/features/users/UserDetail.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import { Link } from 'react-router-dom' 5 | import { 6 | usersSelectors, 7 | followUser, 8 | unFollowUser, 9 | // selectUserPosts, 10 | getUserTimeline 11 | } from './usersSlice' 12 | import { selectUserPosts } from 'features/posts/postsSlice' 13 | 14 | // import Spinner from 'comps/Spinner' 15 | import PostsList from 'comps/PostsList' 16 | import Heading from 'comps/Heading' 17 | import FollowButton from 'comps/FollowButton' 18 | import { Row, Figure, Col } from 'react-bootstrap' 19 | import ScrollToTop from 'comps/ScrollToTop' 20 | import { numFormatter } from 'utils/helpers' 21 | import Spinner from 'comps/Spinner' 22 | import WithUrls from 'comps/with-urls' 23 | 24 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 25 | import { faLocationArrow as faLocation } from '@fortawesome/free-solid-svg-icons/faLocationArrow' 26 | import { faCalendarAlt as faDate } from '@fortawesome/free-solid-svg-icons/faCalendarAlt' 27 | import { faLink } from '@fortawesome/free-solid-svg-icons/faLink' 28 | 29 | export default props => { 30 | let dispatch = useDispatch() 31 | let { match: { params: { username } = {} } = {} } = props 32 | let user = useSelector(state => usersSelectors.selectById(state, username)) 33 | let { user: authUser } = useSelector(state => state.auth) 34 | let posts = useSelector(state => selectUserPosts(state, user && user.screen_name)) 35 | let { user_timeline_status: status } = useSelector(state => state.users) 36 | let getPosts = useCallback(() => { 37 | dispatch(getUserTimeline(username)) 38 | // eslint-disable-next-line 39 | }, [username]) 40 | if (status === 'loading' && !user) 41 | return 42 | let userPosts = (<> 43 | 48 | ) 49 | let userDetail 50 | if (!user) 51 | userDetail =
User not found
52 | else if (user) { 53 | let { url: { urls: [{ url, expanded_url } = {}] = [] } = {} } = user.entities 54 | let banner_color = user.profile_banner_color || '#f5f8fa' 55 | const isNotifEnabled = user.notifications_enabled_device_count > 0 56 | userDetail = (<> 57 | 58 | 59 |
62 | {!user.profile_banner_color && } 66 |
67 |
68 | 69 |
73 | 77 |
78 | {authUser && authUser.screen_name === user.screen_name ? ( 79 | Edit profile 83 | ) : ( 84 | { dispatch(followUser(user.screen_name)) }} 87 | unFollowUser={() => { dispatch(unFollowUser(user.screen_name)) }} 88 | /> 89 | )} 90 |
91 |
92 |
{user.name}
93 |
@{user.screen_name}
94 |
95 |
{user.description}
96 | 97 | 98 |
99 | 100 | {user.location || 'Location unknown'} 101 |
102 | 103 | 104 |
105 | 106 | Joined {new Date(user.created_at).toDateString()} 107 |
108 | 109 | 110 |
111 | 112 | {expanded_url || url} 113 | {/* {display_url || url || expanded_url || 'Just here'} */} 114 |
115 | 116 |
117 | 118 | {numFormatter(user.followers_count)} Followers 122 | 123 | {numFormatter(user.friends_count)} Following 127 | 128 | {isNotifEnabled ? "Notifications enabled" : "Notifications disabled"} 129 | 130 |
131 |
{user.statuses_count} Posts
132 | ) 133 | } 134 | return (<> 135 | {userDetail} 136 | {userPosts} 137 | ) 138 | } -------------------------------------------------------------------------------- /src/features/users/UserSuggests.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect } from 'react' 3 | import TryAgain from 'comps/TryAgain' 4 | import Spinner from 'comps/Spinner' 5 | import UsersList from 'comps/UsersList' 6 | 7 | import { useSelector, useDispatch } from 'react-redux' 8 | import { 9 | getUserSuggests, 10 | selectSuggests, 11 | followUser, 12 | unFollowUser 13 | } from './usersSlice' 14 | 15 | export default props => { 16 | let dispatch = useDispatch() 17 | let { user_suggests_status: status } = useSelector(state => state.users) 18 | let users = useSelector(selectSuggests) 19 | useEffect(() => { 20 | if (status === 'idle') 21 | dispatch(getUserSuggests()) 22 | // eslint-disable-next-line 23 | }, []) 24 | let { message } = props; 25 | 26 | if (status === 'error' && !users.length) 27 | return { dispatch(getUserSuggests()) }} /> 28 | 29 | else if (status === 'loading' && !users.length) 30 | return 31 | 32 | if (!users.length) 33 | return ( 34 |
35 | {message || 'No user suggestions for you RN'} 36 |
37 | ) 38 | 39 | return (<> 40 | { dispatch(followUser(username)) }} 44 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 45 | /> 46 | ) 47 | } -------------------------------------------------------------------------------- /src/features/users/post-likes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import Heading from 'comps/Heading' 4 | import UsersList from 'comps/UsersList' 5 | 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { selectLikes, getLikes, followUser, unFollowUser } from './usersSlice' 8 | 9 | export default props => { 10 | const dispatch = useDispatch() 11 | const { match: { params: { postId } = {} } = {} } = props 12 | 13 | const users = useSelector(state => selectLikes(state, postId)) 14 | const { post_likes_status: status } = useSelector(state => state.users) 15 | 16 | const getUsers = useCallback(() => { 17 | dispatch(getLikes(postId)) 18 | }, [postId, dispatch]) 19 | return (<> 20 | 25 | { dispatch(followUser(username)) }} 27 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 28 | status={status} 29 | users={users} 30 | getUsers={getUsers} 31 | noPop 32 | /> 33 | ) 34 | } -------------------------------------------------------------------------------- /src/features/users/post-reposts.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import Heading from 'comps/Heading' 4 | import UsersList from 'comps/UsersList' 5 | 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { selectReposts, getReposts, followUser, unFollowUser } from './usersSlice' 8 | 9 | export default props => { 10 | const dispatch = useDispatch() 11 | const { match: { params: { postId } = {} } = {} } = props 12 | 13 | const users = useSelector(state => selectReposts(state, postId)) 14 | const { post_reposts_status: status } = useSelector(state => state.users) 15 | 16 | const getUsers = useCallback(() => { 17 | dispatch(getReposts(postId)) 18 | }, [postId, dispatch]) 19 | return (<> 20 | 25 | { dispatch(followUser(username)) }} 27 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 28 | status={status} 29 | users={users} 30 | getUsers={getUsers} 31 | noPop 32 | /> 33 | ) 34 | } -------------------------------------------------------------------------------- /src/features/users/user-followers.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { getFollowers, selectFollowers, followUser, unFollowUser } from './usersSlice' 5 | import UserList from 'comps/UsersList' 6 | import Heading from 'comps/Heading' 7 | 8 | export default props => { 9 | const dispatch = useDispatch() 10 | const { match: { params: { username } = {} } = {} } = props 11 | 12 | const users = useSelector(state => selectFollowers(state, username)) 13 | const { user_followerlist_status: status } = useSelector(state => state.users) 14 | 15 | const getUsers = useCallback(() => { 16 | dispatch(getFollowers(username)) 17 | }, [username, dispatch]) 18 | return (<> 19 | 24 | { dispatch(followUser(username)) }} 26 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 27 | getUsers={getUsers} 28 | status={status} 29 | users={users} 30 | noPop 31 | /> 32 | ) 33 | } -------------------------------------------------------------------------------- /src/features/users/user-friends.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useCallback } from 'react' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { getFriends, selectFriends, followUser, unFollowUser } from './usersSlice' 5 | import UserList from 'comps/UsersList' 6 | import Heading from 'comps/Heading' 7 | 8 | export default props => { 9 | const dispatch = useDispatch() 10 | const { match: { params: { username } = {} } = {} } = props 11 | 12 | const users = useSelector(state => selectFriends(state, username)) 13 | const { user_friendlist_status: status } = useSelector(state => state.users) 14 | 15 | const getUsers = useCallback(() => { 16 | dispatch(getFriends(username)) 17 | }, [username, dispatch]) 18 | return (<> 19 | 24 | { dispatch(followUser(username)) }} 26 | unFollowUser={username => { dispatch(unFollowUser(username)) }} 27 | status={status} 28 | users={users} 29 | getUsers={getUsers} 30 | noPop 31 | /> 32 | ) 33 | } -------------------------------------------------------------------------------- /src/features/users/usersSlice.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createAsyncThunk, 4 | createEntityAdapter, 5 | createSelector, 6 | } from '@reduxjs/toolkit' 7 | import { request } from 'api' 8 | import { getFeed, selectUserPosts } from 'features/posts/postsSlice' 9 | import { parsePosts } from 'features/posts/utils' 10 | import { userUpdated as authUserUpdated } from 'store/authSlice' 11 | 12 | let usersComparer = (a, b) => { 13 | let statusesEither = b.statuses_count || a.statuses_count 14 | if (statusesEither && statusesEither > 3) { 15 | return b.statuses_count - a.statuses_count 16 | } else if (b.followers_count || a.followers_count) { 17 | return b.followers_count - a.followers_count 18 | } 19 | return b.statuses_count - a.statuses_count 20 | } 21 | 22 | const usersAdapter = createEntityAdapter({ 23 | selectId: user => user.screen_name, 24 | // sortComparer: usersComparer 25 | }) 26 | const initialState = usersAdapter.getInitialState({ 27 | user_suggests_status: 'idle', 28 | user_timeline_status: 'idle', 29 | user_timeline_page: 0, 30 | 31 | user_update_status: 'idle', 32 | 33 | user_friendlist_status: 'idle', 34 | user_friendlist_page: 0, 35 | 36 | user_followerlist_status: 'idle', 37 | user_followerlist_page: 0, 38 | 39 | post_likes_status: 'idle', 40 | post_likes_page: 0, 41 | post_reposts_status: 'idle', 42 | post_reposts_page: 0, 43 | }) 44 | 45 | export const updateUserDetails = createAsyncThunk( 46 | 'users/updateUserDetails', 47 | async (body, { dispatch }) => { 48 | let { user } = await request('/api/updateuser', { body, dispatch }) 49 | if (!user) throw Error('User feild nill in responce') 50 | dispatch(authUserUpdated(user)) 51 | return dispatch(userAdded(user)) 52 | } 53 | ) 54 | 55 | export const getUserSuggests = createAsyncThunk( 56 | 'users/getUserSuggests', 57 | async (_, { dispatch }) => { 58 | let data = await request('/api/users', { dispatch }) 59 | // console.log(data.users) 60 | return data.users 61 | } 62 | ) 63 | export const getUserTimeline = createAsyncThunk( 64 | 'users/getUserTimeline', 65 | async (username, { dispatch, getState }) => { 66 | let { user_timeline_page: p } = getState().users 67 | let l = selectUserPosts(getState(), username).length 68 | if (!l || l === 0) { 69 | dispatch(resetTimelinePage()) 70 | p = 0 71 | } 72 | let url = `/api/user_timeline/${username}?p=${p + 1}` 73 | let { posts, user } = await request(url, { dispatch }) 74 | if (user) { 75 | dispatch(userAdded(user)) 76 | } 77 | dispatch(parsePosts(posts)) 78 | return posts.length 79 | } 80 | ) 81 | 82 | export const followUser = createAsyncThunk( 83 | 'users/folllowUser', 84 | async (username, { dispatch, getState }) => { 85 | dispatch(followingChanged({ username, following: true })) 86 | username = encodeURIComponent(username) 87 | await request(`/api/follow/${username}`, { dispatch, body: {} }) 88 | let feedStatus = getState().posts.feed_status 89 | if (feedStatus === 'done') dispatch(getFeed()) 90 | } 91 | ) 92 | export const unFollowUser = createAsyncThunk( 93 | 'users/unFolllowUser', 94 | async (username, { dispatch }) => { 95 | dispatch(followingChanged({ username, following: false })) 96 | username = encodeURIComponent(username) 97 | return request(`/api/unfollow/${username}`, { dispatch, body: {} }) 98 | } 99 | ) 100 | export const getFollowers = createAsyncThunk( 101 | 'users/getFollowers', 102 | async (username, { dispatch, getState }) => { 103 | let { 104 | users: { user_followerlist_page: p }, 105 | } = getState() 106 | let l = selectFollowers(getState(), username).length 107 | if (!l) { 108 | dispatch(resetFollowerlistPage()) 109 | p = 0 110 | } 111 | p = parseInt(p) 112 | username = encodeURIComponent(username) 113 | let { users = [] } = await request(`/api/followers/${username}?p=${p + 1}`, { dispatch }) 114 | users = users || [] 115 | if (!users.length) return 116 | users = users 117 | .map(user => ({ ...user, follower_of: decodeURIComponent(username) })) 118 | .filter(Boolean) 119 | dispatch(usersAdded(users)) 120 | return users.length 121 | } 122 | ) 123 | export const getFriends = createAsyncThunk( 124 | 'users/getFriends', 125 | async (username, { dispatch, getState }) => { 126 | let { 127 | users: { user_friendlist_page: p }, 128 | } = getState() 129 | let l = selectFriends(getState(), username).length 130 | if (!l) { 131 | dispatch(resetFriendlistPage()) 132 | p = 0 133 | } 134 | p = parseInt(p) 135 | username = encodeURIComponent(username) 136 | let { users = [] } = await request(`/api/friends/${username}?p=${p + 1}`, { dispatch }) 137 | users = users || [] 138 | if (!users.length) return 139 | users = users 140 | .map(user => ({ ...user, friend_of: decodeURIComponent(username) })) 141 | .filter(Boolean) 142 | dispatch(usersAdded(users)) 143 | return users.length 144 | } 145 | ) 146 | export const getLikes = createAsyncThunk( 147 | 'users/getLikes', 148 | async (postId, { dispatch, getState }) => { 149 | try { 150 | let { 151 | users: { post_likes_page: p }, 152 | } = getState() 153 | p = parseInt(p) 154 | let l = selectLikes(getState(), postId).length 155 | if (!l) { 156 | dispatch(resetLikesPage()) 157 | p = 0 158 | } 159 | let { users = [] } = await request(`/api/post/${postId}/likes?p=${p + 1}`, { dispatch }) 160 | users = users || [] 161 | if (!users.length) return 162 | users = users.map(user => ({ ...user, liked_post: postId })).filter(Boolean) 163 | dispatch(usersAdded(users)) 164 | return users.length 165 | } catch (err) { 166 | console.log(err) 167 | throw err 168 | } 169 | } 170 | ) 171 | export const getReposts = createAsyncThunk( 172 | 'users/getReposts', 173 | async (postId, { dispatch, getState }) => { 174 | let { 175 | users: { post_reposts_page: p }, 176 | } = getState() 177 | p = parseInt(p) 178 | let l = selectReposts(getState(), postId).length 179 | if (!l) { 180 | dispatch(resetRepostsPage()) 181 | p = 0 182 | } 183 | let { users = [] } = await request(`/api/post/${postId}/reposts?p=${p + 1}`, { dispatch }) 184 | users = users || [] 185 | if (!users.length) return 186 | users = users.map(user => ({ ...user, reposted_post: postId })).filter(Boolean) 187 | dispatch(usersAdded(users)) 188 | return users.length 189 | } 190 | ) 191 | 192 | const usersSlice = createSlice({ 193 | name: 'users', 194 | initialState, 195 | reducers: { 196 | followingChanged: (state, action) => { 197 | let { username, following } = action.payload 198 | usersAdapter.updateOne(state, { 199 | id: username, 200 | changes: { 201 | following, 202 | new: true, 203 | }, 204 | }) 205 | }, 206 | resetTimelinePage: state => { 207 | state.user_timeline_page = 0 208 | }, 209 | resetFollowerlistPage: state => { 210 | state.user_followerlist_page = 0 211 | }, 212 | resetFriendlistPage: state => { 213 | state.user_friendlist_page = 0 214 | }, 215 | resetLikesPage: state => { 216 | state.post_likes_page = 0 217 | }, 218 | resetRepostsPage: state => { 219 | state.post_reposts_page = 0 220 | }, 221 | userAdded: usersAdapter.upsertOne, 222 | usersAdded: usersAdapter.upsertMany, 223 | usersAddedDontUpdate: usersAdapter.addMany, 224 | }, 225 | extraReducers: { 226 | [getUserSuggests.rejected]: state => { 227 | state.user_suggests_status = 'error' 228 | }, 229 | [getUserSuggests.pending]: state => { 230 | state.user_suggests_status = 'loading' 231 | }, 232 | [getUserSuggests.fulfilled]: (state, action) => { 233 | state.user_suggests_status = 'idle' 234 | // console.log(action.payload) 235 | usersAdapter.addMany(state, action.payload) 236 | }, 237 | [getUserTimeline.rejected]: state => { 238 | state.user_timeline_status = 'error' 239 | }, 240 | [getUserTimeline.pending]: state => { 241 | state.user_timeline_status = 'loading' 242 | }, 243 | [getUserTimeline.fulfilled]: (state, action) => { 244 | let length = action.payload 245 | if (length > 0) { 246 | state.user_timeline_status = 'idle' 247 | state.user_timeline_page += 1 248 | } else state.user_timeline_status = 'done' 249 | }, 250 | [updateUserDetails.rejected]: state => { 251 | state.user_update_status = 'error' 252 | }, 253 | [updateUserDetails.pending]: state => { 254 | state.user_update_status = 'pending' 255 | }, 256 | [updateUserDetails.fulfilled]: state => { 257 | state.user_update_status = 'idle' 258 | }, 259 | 260 | [getFollowers.rejected]: state => { 261 | state.user_followerlist_status = 'error' 262 | }, 263 | [getFollowers.pending]: state => { 264 | state.user_followerlist_status = 'loading' 265 | }, 266 | [getFollowers.fulfilled]: (state, action) => { 267 | const length = action.payload 268 | if (length > 0) { 269 | state.user_followerlist_status = 'idle' 270 | state.user_followerlist_page += 1 271 | } else state.user_followerlist_status = 'done' 272 | }, 273 | 274 | [getFriends.rejected]: state => { 275 | state.user_friendlist_status = 'error' 276 | }, 277 | [getFriends.pending]: state => { 278 | state.user_friendlist_status = 'loading' 279 | }, 280 | [getFriends.fulfilled]: (state, action) => { 281 | const length = action.payload 282 | if (length > 0) { 283 | state.user_friendlist_status = 'idle' 284 | state.user_friendlist_page += 1 285 | } else state.user_friendlist_status = 'done' 286 | }, 287 | [getLikes.rejected]: state => { 288 | state.post_likes_status = 'error' 289 | }, 290 | [getLikes.pending]: state => { 291 | state.post_likes_status = 'loading' 292 | }, 293 | [getLikes.fulfilled]: (state, action) => { 294 | const length = action.payload 295 | if (length > 0) { 296 | state.post_likes_status = 'idle' 297 | state.post_likes_page += 1 298 | } else state.post_likes_status = 'done' 299 | }, 300 | 301 | [getReposts.rejected]: state => { 302 | state.post_reposts_status = 'error' 303 | }, 304 | [getReposts.pending]: state => { 305 | state.post_reposts_status = 'loading' 306 | }, 307 | [getReposts.fulfilled]: (state, action) => { 308 | const length = action.payload 309 | if (length > 0) { 310 | state.post_reposts_status = 'idle' 311 | state.post_reposts_page += 1 312 | } else state.post_reposts_status = 'done' 313 | }, 314 | }, 315 | }) 316 | const { actions, reducer } = usersSlice 317 | export default reducer 318 | export const { 319 | followingChanged, 320 | userAdded, 321 | usersAdded, 322 | resetTimelinePage, 323 | resetFollowerlistPage, 324 | resetFriendlistPage, 325 | usersAddedDontUpdate, 326 | resetLikesPage, 327 | resetRepostsPage, 328 | } = actions 329 | 330 | export const usersSelectors = usersAdapter.getSelectors(state => state.users) 331 | 332 | export const selectSuggests = createSelector(usersSelectors.selectAll, users => 333 | users.filter(user => user.following === false || user.new === true).sort(usersComparer) 334 | ) 335 | 336 | export const selectSearchUsers = createSelector( 337 | [usersSelectors.selectAll, (state, query) => query], 338 | (users, query) => users.filter(user => user.searched === true && user.query === query) 339 | ) 340 | 341 | export const selectFriends = createSelector( 342 | [usersSelectors.selectAll, (_, username) => username], 343 | (users, username) => 344 | users 345 | .filter(user => user.friend_of === username) 346 | .filter(user => user.friend_of !== user.screen_name) 347 | ) 348 | export const selectFollowers = createSelector( 349 | [usersSelectors.selectAll, (_, username) => username], 350 | (users, username) => 351 | users 352 | .filter(user => user.follower_of === username) 353 | .filter(user => user.follower_of !== user.screen_name) 354 | ) 355 | export const selectLikes = createSelector( 356 | [usersSelectors.selectAll, (_, postId) => postId], 357 | (users, postId) => users.filter(user => user.liked_post === postId) 358 | ) 359 | export const selectReposts = createSelector( 360 | [usersSelectors.selectAll, (_, postId) => postId], 361 | (users, postId) => users.filter(user => user.reposted_post === postId) 362 | ) 363 | 364 | // export { selectUserPosts } from 'features/posts/postsSlice' 365 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Splash from './comps/splash' 4 | import TryAgain from './comps/TryAgain' 5 | import * as serviceWorker from './serviceWorkerReg'; 6 | 7 | import JavascriptTimeAgo from 'javascript-time-ago' 8 | // The desired locales. 9 | import en from 'javascript-time-ago/locale/en' 10 | import { useEffect } from 'react'; 11 | 12 | import { Provider, useDispatch, useSelector } from 'react-redux' 13 | import { login } from 'store/authSlice' 14 | import store from 'store/' 15 | 16 | import App from 'pages/App' 17 | import Landing from 'pages/Landing' 18 | 19 | import './styles/main.scss'; 20 | // Initialize the desired locales. 21 | JavascriptTimeAgo.locale(en) 22 | 23 | ReactDOM.render( 24 | 25 | }> 26 | 27 | 28 | , 29 | document.getElementById('root') 30 | ); 31 | function Root() { 32 | const { status, isAuthenticated, user } = useSelector(state => state.auth) 33 | const dispatch = useDispatch(); 34 | useEffect(() => { 35 | dispatch(login()); 36 | // eslint-disable-next-line 37 | }, []) 38 | if (isAuthenticated && user) 39 | return 40 | else if (status === "loading") 41 | return 42 | else if (status === "error") 43 | return { dispatch(login()) }} message='Something went wrong, check you connection and try again' /> 44 | else if (!(isAuthenticated && user)) 45 | return 46 | } 47 | 48 | 49 | // If you want your app to work offline and load faster, you can change 50 | // unregister() to register() below. Note this comes with some pitfalls. 51 | // Learn more about service workers: https://bit.ly/CRA-PWA 52 | serviceWorker.register(); 53 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/layouts/header/bottom-nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faHome } from '@fortawesome/free-solid-svg-icons/faHome' 5 | import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch' 6 | import { faBell } from '@fortawesome/free-regular-svg-icons/faBell' 7 | import { faPlusCircle } from '@fortawesome/free-solid-svg-icons/faPlusCircle' 8 | import { faUser } from '@fortawesome/free-regular-svg-icons/faUser' 9 | 10 | import { NavLink, Link } from 'react-router-dom' 11 | import { Badge } from 'react-bootstrap' 12 | 13 | import { useSelector } from 'react-redux' 14 | import { selectUnread } from 'features/notify/notifySlice' 15 | 16 | function Nav() { 17 | let notifsCount = useSelector(selectUnread).length 18 | let { user: { screen_name } } = useSelector(state => state.auth) 19 | let list = [ 20 | { 21 | name: "Home", 22 | href: "/home", 23 | icon: faHome 24 | }, 25 | { 26 | name: "Explore", 27 | href: "/explore", 28 | icon: faSearch 29 | }, 30 | { 31 | name: "Notifications", 32 | href: "/notifications", 33 | icon: faBell, 34 | count: notifsCount 35 | }, 36 | { 37 | name: "Profile", 38 | href: `/user/${screen_name}`, 39 | icon: faUser, 40 | } 41 | ] 42 | let compose = { 43 | name: "Tweet", 44 | icon: faPlusCircle, 45 | href: '/compose/post', 46 | style: { 47 | right: '.5em', 48 | bottom: '4em', 49 | fontSize: '1.1em' 50 | } 51 | } 52 | return ( 53 |
54 | 55 | 56 | 57 | {list.map(item => { 58 | let vis = item.disabled ? 'disabled' : '' 59 | let badge = item.count ? <>{item.count}new items : null 60 | return (
61 | 67 | 71 | 72 | {badge} 73 |
) 74 | })} 75 |
76 | ) 77 | } 78 | export default Nav -------------------------------------------------------------------------------- /src/layouts/header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | // import { faTwitter } from '@fortawesome/free-brands-svg-icons/faTwitter' 4 | import { faBell } from '@fortawesome/free-regular-svg-icons/faBell' 5 | import { faEnvelope } from '@fortawesome/free-regular-svg-icons/faEnvelope' 6 | // import { faComments } from '@fortawesome/free-regular-svg-icons/faComments' 7 | // import { faListAlt } from '@fortawesome/free-regular-svg-icons/faListAlt' 8 | import { faUser } from '@fortawesome/free-regular-svg-icons/faUser' 9 | import { faHome } from '@fortawesome/free-solid-svg-icons/faHome' 10 | import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH' 11 | import { faHashtag } from '@fortawesome/free-solid-svg-icons/faHashtag' 12 | import { faPlusCircle } from '@fortawesome/free-solid-svg-icons/faPlusCircle' 13 | 14 | import { NavLink, Link } from 'react-router-dom' 15 | import { Col, Badge } from 'react-bootstrap' 16 | 17 | import { useSelector } from 'react-redux' 18 | import { selectUnread } from 'features/notify/notifySlice' 19 | 20 | function Header(props) { 21 | let notifsCount = useSelector(selectUnread).length 22 | let { user: { screen_name } } = useSelector(state => state.auth) 23 | let logo = { 24 | href: "/home", 25 | } 26 | let compose = { 27 | name: "Post", 28 | icon: faPlusCircle 29 | } 30 | let list = [ 31 | { 32 | name: "Home", 33 | href: "/home", 34 | icon: faHome 35 | }, 36 | { 37 | name: "Explore", 38 | href: "/explore", 39 | icon: faHashtag 40 | }, 41 | { 42 | name: "Profile", 43 | href: `/user/${screen_name}`, 44 | icon: faUser, 45 | }, 46 | { 47 | name: "Notifications", 48 | href: "/notifications", 49 | icon: faBell, 50 | count: notifsCount 51 | }, 52 | // { 53 | // name: "Chat Room", 54 | // href: "/chats", 55 | // icon: faComments 56 | // }, 57 | { 58 | name: "Settings", 59 | href: "/settings", 60 | icon: faEllipsisH 61 | }, 62 | { 63 | name: "Messages", 64 | href: "/messages", 65 | icon: faEnvelope, 66 | disabled: true 67 | }, 68 | 69 | ] 70 | return ( 71 | 72 |
73 | 76 | {/* */} 77 | logo 78 | 79 |
80 |
81 | {list.map(itm => { 82 | let vis = itm.disabled ? "disabled" : "" 83 | let badge = itm.count ? <>{itm.count}new items : null 84 | return (
85 | 90 | 91 | {itm.name} 92 | 93 | {badge} 94 |
) 95 | })} 96 |
97 | 98 | 99 | {compose.name} 100 | 101 | 102 | 103 | ) 104 | } 105 | 106 | export default Header -------------------------------------------------------------------------------- /src/layouts/landing/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { filterInput } from 'utils/helpers' 4 | // import { AuthContext } from 'utils/context/auth' 5 | import { connect } from 'react-redux' 6 | import { login } from 'store/authSlice' 7 | import { Figure, Form, Col } from 'react-bootstrap' 8 | 9 | class Login extends React.Component { 10 | // static contextType = AuthContext; 11 | state = { 12 | disabled: false, 13 | error: null, 14 | password: '', 15 | username: '' 16 | } 17 | handleChange = (e) => { 18 | this.setState({ 19 | [e.target.name]: e.target.value 20 | }) 21 | } 22 | handleSubmit = async (e) => { 23 | e.preventDefault() 24 | if (this.state.disabled) 25 | return 26 | this.setState({ error: null, disabled: true }) 27 | try { 28 | let form = e.target 29 | let username = filterInput(form.username.value, 'username', { min_length: 4 }) 30 | let password = filterInput(form.password.value, 'password') 31 | let responce = await fetch('/auth/login', { 32 | method: 'POST', 33 | body: JSON.stringify({ 34 | username, 35 | password 36 | }), 37 | headers: { 38 | 'Content-Type': 'application/json' 39 | } 40 | }) 41 | // console.log(responce); 42 | if (responce.status >= 500) { 43 | throw Error('Something went wrong.') 44 | } 45 | else if (responce.status >= 400) { 46 | throw Error('Incorrect credentials') 47 | } 48 | else if (responce.ok) { 49 | let data = await responce.json() 50 | console.log(data.message) 51 | this.setState({ disabled: false }) 52 | this.props.login(data.user) 53 | } 54 | } catch (error) { 55 | console.log(error.message) 56 | this.setState({ error: error.message, disabled: false }) 57 | } 58 | } 59 | render() { 60 | let disabled = this.state.disabled 61 | return ( 62 | 63 | {!this.props.compact && ( 64 |
65 | 72 | 73 | People vector created by pikisuperstar - www.freepik.com 74 | 75 |
76 | )} 77 |
78 | Login to see what’s happening in the muzamilverse right now 79 |
80 |
81 |
82 | 83 | Username 84 | 91 | 92 | 93 | Password 94 | 101 | 102 |

103 | {/* Forgot password? 104 |
*/} 105 | {this.state.error} 106 |

107 |
108 | 111 | or 112 | 116 | Sign up 117 | 118 |
119 |
120 |
121 | 122 | ) 123 | } 124 | } 125 | export default connect(null, { login })(Login) -------------------------------------------------------------------------------- /src/layouts/landing/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | // import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | // import { faTwitter } from '@fortawesome/free-brands-svg-icons/faTwitter' 4 | import Search from 'comps/SearchBar' 5 | import { Link } from 'react-router-dom' 6 | import { Navbar, Row, Container } from 'react-bootstrap' 7 | 8 | class Navigationbar extends React.Component { 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | logo 15 | {/* */} 16 | 17 | 18 | 19 | Log in 20 | Sign up 21 | 22 | 23 | 24 | ) 25 | } 26 | } 27 | export default Navigationbar -------------------------------------------------------------------------------- /src/layouts/landing/Signup.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { filterInput } from 'utils/helpers' 4 | import { connect } from 'react-redux' 5 | import { login } from 'store/authSlice' 6 | import { Figure, Form, Col } from 'react-bootstrap' 7 | 8 | class Signup extends React.Component { 9 | state = { 10 | disabled: false, 11 | error: null 12 | } 13 | handleSubmit = async (e) => { 14 | e.preventDefault() 15 | if (this.state.disabled) 16 | return 17 | this.setState({ error: null, disabled: true }) 18 | try { 19 | let form = e.target 20 | let username = filterInput(form.username.value, 'username', { min_length: 4 }) 21 | let password = filterInput(form.password.value, 'password') 22 | let fullname = filterInput(form.fullname.value, 'name', { min_length: 0 }) 23 | let responce = await fetch('/auth/signup', { 24 | method: 'POST', 25 | body: JSON.stringify({ 26 | username, 27 | password, 28 | fullname 29 | }), 30 | headers: { 31 | 'Content-Type': 'application/json' 32 | } 33 | }) 34 | if (!responce.ok) { 35 | if (responce.status === 409) //conflict 36 | throw Error((await responce.json()).message) 37 | throw Error('Something went wrong') 38 | } 39 | let data = await responce.json() 40 | console.log(data.message) 41 | this.setState({ disabled: false }) 42 | this.props.login(data.user) 43 | } catch (error) { 44 | console.log(error.message) 45 | this.setState({ error: error.message, disabled: false }) 46 | } 47 | } 48 | render() { 49 | let disabled = this.state.disabled 50 | return ( 51 | 52 |
53 | 60 | 61 | People vector created by pikisuperstar - www.freepik.com 62 | 63 |
64 |
65 | Signup to see what’s happening in the muzamilverse right now 66 |
67 |
68 |
69 | 70 | Choose a username - required 71 | 77 | 78 | 79 | Full name - optional 80 | 85 | 86 | 87 | Choose a password - required 88 | 92 | 93 |

94 | Already have account? login instead 95 |
96 | {this.state.error} 97 |

98 |
99 | 104 |
or
105 | 108 | Log in 109 | 110 |
111 |
112 |
113 | 114 | ) 115 | } 116 | } 117 | export default connect(null, { login })(Signup) -------------------------------------------------------------------------------- /src/layouts/main/Explore.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Search from 'comps/SearchBar' 4 | import Trends from 'features/trends/Trends' 5 | import MediaQuery from 'react-responsive' 6 | import FollowCard from './sidebar/FollowCard' 7 | import Users from 'features/users/UserSuggests' 8 | import Heading from 'comps/Heading' 9 | import { Route, Switch } from 'react-router-dom' 10 | import { Figure } from 'react-bootstrap' 11 | 12 | export default (props) => { 13 | 14 | return (<> 15 |
16 | {!props.noSearchBar && 17 | 18 | 19 | } 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | {!props.noSuggestions && ( 28 | 29 | 30 | 31 | )} 32 | 33 | {!props.compact && ( 34 |
35 | 36 | Brochure vector created by katemangostar - www.freepik.com 37 |
38 | )} 39 | 40 |
41 |
42 | ) 43 | } -------------------------------------------------------------------------------- /src/layouts/main/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Compose from 'features/posts/Compose' 3 | import Feed from 'features/posts/Feed' 4 | import Heading from 'comps/Heading' 5 | import MediaQuery from 'react-responsive' 6 | 7 | class Home extends React.Component { 8 | render() { 9 | return (<> 10 | 11 | 12 | 13 |
14 |
15 | 16 | ) 17 | } 18 | } 19 | 20 | export default Home -------------------------------------------------------------------------------- /src/layouts/main/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Home from './Home' 3 | import Sidebar from './sidebar' 4 | import MediaQuery from 'react-responsive' 5 | import { Row, Col } from 'react-bootstrap' 6 | 7 | import { Route, Switch } from 'react-router-dom' 8 | import PostDetail from 'features/posts/PostDetail' 9 | import Explore from './Explore' 10 | import Search from 'features/search/Search' 11 | import UserDetail from 'features/users/UserDetail' 12 | import Compose from 'features/posts/compose-modal' 13 | import Notifies from 'features/notify/notify-page' 14 | import Settings from 'features/settings/settings-page.js' 15 | 16 | import UserFriends from 'features/users/user-friends' 17 | import UserFollowers from 'features/users/user-followers' 18 | 19 | import PostLikes from 'features/users/post-likes' 20 | import PostReposts from 'features/users/post-reposts' 21 | 22 | import ChatRoom from 'comps/chat-room-placeholder' 23 | 24 | import { useAlerts } from 'features/alerts/alertsContext' 25 | import { useEffect } from 'react' 26 | 27 | export default props => { 28 | const { ensureCompleteProfile } = useAlerts() 29 | useEffect(() => { 30 | ensureCompleteProfile() 31 | // eslint-disable-next-line 32 | }, []) 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | } -------------------------------------------------------------------------------- /src/layouts/main/sidebar/FollowCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { Card } from 'react-bootstrap' 4 | import { useSelector } from 'react-redux' 5 | import Users from 'features/users/UserSuggests' 6 | 7 | function FollowCard(props) { 8 | let { isAuthenticated } = useSelector(state => state.auth) 9 | let footer = { 10 | href: "/explore/users" 11 | } 12 | let { className, ...rest } = props; 13 | return ( 14 | 15 | {props.title} 16 | {isAuthenticated ? 17 | : 18 |
Login to see users and their posts
19 | } 20 | 21 | Show more 25 | 26 |
27 | ) 28 | } 29 | export default FollowCard; -------------------------------------------------------------------------------- /src/layouts/main/sidebar/TrendingCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { Card } from 'react-bootstrap' 4 | import Trends from 'features/trends/Trends' 5 | 6 | function TrendingCard(props) { 7 | let footer = { 8 | href: "/explore" 9 | } 10 | let { className } = props; 11 | return ( 12 | 13 | {props.title} 14 | {/* ListGroup */} 15 | 16 | 17 | Show more 18 | 19 | 20 | ) 21 | } 22 | export default TrendingCard; -------------------------------------------------------------------------------- /src/layouts/main/sidebar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Search from 'comps/SearchBar' 3 | import FollowCard from './FollowCard' 4 | import TrendingCard from './TrendingCard' 5 | import { Col } from 'react-bootstrap' 6 | 7 | import { useLocation } from 'react-router-dom' 8 | 9 | function Sidebar() { 10 | const location = useLocation() 11 | return ( 12 | 13 | 14 | 15 | {!(location.pathname === '/explore/users') ? ( 16 | 17 | ) : undefined} 18 | {/*
*/} 19 | {!(location.pathname === '/explore') ? ( 20 | 21 | ) : undefined} 22 | 40 | 41 | ) 42 | } 43 | 44 | export default Sidebar 45 | -------------------------------------------------------------------------------- /src/pages/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Main from 'layouts/main' 3 | import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom' 4 | import MediaQuery from 'react-responsive' 5 | import { Row, Col, Container } from 'react-bootstrap' 6 | import Nav from 'layouts/header/bottom-nav' 7 | import Header from 'layouts/header' 8 | 9 | 10 | import { useDispatch } from 'react-redux' 11 | import { fetchNotifs } from 'features/notify/notifySlice' 12 | import { useEffect } from 'react' 13 | import { AlertsProvider } from 'features/alerts/alertsContext' 14 | 15 | import { subscribeUserToPush } from '../subscription' 16 | 17 | export default function App() { 18 | const dispatch = useDispatch() 19 | 20 | useEffect(() => { 21 | dispatch(fetchNotifs()) 22 | }, [dispatch]) 23 | 24 | useEffect(() => { 25 | if (window.Notification?.permission === 'granted') 26 | subscribeUserToPush() 27 | }, []) 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 |