├── .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 | [](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 | 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 | You need to enable JavaScript to run this app. 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 | 52 | {hoverText || text} 53 | 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 = ( { isAuthenticated ? history.goBack() : history.push('/') }} 24 | className="ml-2 btn btn-naked-primary rounded-circle text-primary"> 25 | 26 | ) 27 | if (btnLogout) 28 | btnLogout = ( { dispatch(logout()) }} 29 | onMouseEnter={() => { setBtnTxt("Bola naa yaar") }} 30 | onMouseLeave={() => { setBtnTxt("Never click it") }} 31 | className="btn btn-outline-primary rounded-pill px-2 py-1 mr-2 font-weight-bold" 32 | >{btnTxt} 33 | ) 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 = 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 | 16 | 17 | Try again 18 | 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 | {cancelText || "Cancel"} 43 | {confirmText || "Confirm"} 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 | 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 | 109 | 110 | 111 | 112 | 113 | 118 | Post 119 | 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 | 75 | {post.favorited ? ( 76 | 77 | ) : } 78 | 79 | {numFormatter(post.favorite_count)} 80 | 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 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 198 | Post 199 | 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 | 159 | Pick banner color 160 | 161 | 162 | 163 | 164 | 165 | 169 | 170 | 171 | setProfile(getRandomProfileUrl())} 173 | className="btn btn-outline-primary rounded-pill px-2 py-1 btn-sm font-weight-bold" 174 | > 175 | Change Avatar 176 | 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 | 228 | Save 229 | 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 | 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 | 109 | Log in 110 | 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 | 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 | 102 | Sign up 103 | 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 | 51 | 52 | 53 | 54 | 55 | ); 56 | } -------------------------------------------------------------------------------- /src/pages/Landing.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Login from 'layouts/landing/Login' 3 | import Signup from 'layouts/landing/Signup' 4 | import Navbar from 'layouts/landing/Navbar' 5 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' 6 | import { Container, Col, Row } from 'react-bootstrap' 7 | 8 | import MediaQuery from 'react-responsive' 9 | import Explore from 'layouts/main/Explore' 10 | import Search from 'features/search/Search' 11 | import PostDetail from 'features/posts/PostDetail' 12 | import UserDetail from 'features/users/UserDetail' 13 | 14 | export default props => { 15 | return ( 16 | 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 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 | 67 | 68 | > 69 | 70 | ) 71 | } -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-globals: "off" */ 2 | import { precacheAndRoute } from 'workbox-precaching/precacheAndRoute' 3 | import { registerRoute } from 'workbox-routing' 4 | import { ExpirationPlugin } from 'workbox-expiration' 5 | import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies' 6 | import { skipWaiting, clientsClaim } from 'workbox-core' 7 | 8 | skipWaiting() 9 | clientsClaim() 10 | const manifest = self.__WB_MANIFEST || [] 11 | precacheAndRoute(manifest); 12 | 13 | /** 14 | * Offline and caching 15 | */ 16 | 17 | //index doc, stale-while-revalidate 18 | self.addEventListener('install', async event => { 19 | event.waitUntil(caches.open('index').then(cache => cache.add('/index.html'))) 20 | }) 21 | 22 | function cleanResponse(response) { 23 | const clonedResponse = response.clone() 24 | 25 | // Not all browsers support the Response.body stream, so fall back to reading 26 | // the entire body into memory as a blob. 27 | const bodyPromise = 28 | 'body' in clonedResponse ? Promise.resolve(clonedResponse.body) : clonedResponse.blob() 29 | 30 | return bodyPromise.then(body => { 31 | // new Response() is happy when passed either a stream or a Blob. 32 | return new Response(body, { 33 | headers: clonedResponse.headers, 34 | status: clonedResponse.status, 35 | statusText: clonedResponse.statusText, 36 | }) 37 | }) 38 | } 39 | 40 | registerRoute( 41 | ({ request }) => request.destination === 'document', 42 | async ({ event }) => { 43 | const cache = await caches.open('index') 44 | const cachedResponse = await cache.match('/index.html') 45 | const networkResponsePromise = fetch('/index.html') 46 | 47 | event.waitUntil( 48 | (async function () { 49 | const networkResponse = await networkResponsePromise 50 | const cleaned = await cleanResponse(networkResponse) 51 | await cache.put('/index.html', cleaned) 52 | })() 53 | ) 54 | 55 | // Returned the cached response if we have one, otherwise return the network response. 56 | return cachedResponse || networkResponsePromise 57 | } 58 | ) 59 | 60 | // images cache first 61 | registerRoute( 62 | ({ request, url }) => 63 | request.destination === 'image' && 64 | (url.origin === process.env.REACT_APP_API_SERVER || url.origin === self.location.origin), 65 | new CacheFirst({ 66 | cacheName: 'images', 67 | plugins: [ 68 | new ExpirationPlugin({ 69 | maxEntries: 20, 70 | maxAgeSeconds: 2 * 24 * 60 * 60, // 2 Days 71 | }), 72 | ], 73 | }) 74 | ) 75 | // fallback scripts 76 | registerRoute( 77 | ({ request }) => request.destination === 'script', 78 | new StaleWhileRevalidate({ 79 | cacheName: 'scripts', 80 | }) 81 | ) 82 | // requests to cors-anywhere proxy, cache-first 83 | registerRoute( 84 | ({ url }) => url.origin === 'https://cors-anywhere.herokuapp.com', 85 | new CacheFirst({ 86 | cacheName: 'previews', 87 | plugins: [ 88 | new ExpirationPlugin({ 89 | maxEntries: 50, 90 | maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days 91 | }), 92 | ], 93 | }) 94 | ) 95 | /** 96 | * Push n Notifications 97 | */ 98 | 99 | self.addEventListener('push', event => { 100 | const data = event.data.json() 101 | console.info('New notification', data) 102 | const options = { 103 | icon: './android-chrome-192x192.png', 104 | badge: './favicon-32x32.png', 105 | actions: [ 106 | { 107 | action: 'open', 108 | title: 'Open', 109 | }, 110 | { 111 | action: 'close', 112 | title: 'Close', 113 | }, 114 | ], 115 | data: {}, //server may send page field in this eg,{ page: `/post/2334`} 116 | ...data.options, 117 | } 118 | event.waitUntil(self.registration.showNotification(data.title, options)) 119 | }) 120 | 121 | self.addEventListener('notificationclick', function (event) { 122 | const clickedNotification = event.notification 123 | clickedNotification.close() 124 | fetch(`/api/notification_read/${clickedNotification.data._id}`, { 125 | method: 'POST', 126 | }) 127 | if (event.action === 'close') return 128 | else { 129 | //`open` action or just click anywhere on notification 130 | const page = clickedNotification.data.page || '/notifications' 131 | const url = new URL(page, self.location.origin).href 132 | const promiseChain = self.clients 133 | .matchAll({ 134 | type: 'window', 135 | includeUncontrolled: true, 136 | }) 137 | .then(windowClients => { 138 | let matchingClient = windowClients[0] 139 | if (matchingClient) { 140 | return matchingClient 141 | .navigate(url) 142 | .then(matchingClient => matchingClient.focus()) 143 | } else { 144 | return self.clients.openWindow(url) 145 | } 146 | }) 147 | 148 | event.waitUntil(promiseChain) 149 | } 150 | }) 151 | self.addEventListener('notificationclose', function (event) { 152 | const closedNotification = event.notification 153 | fetch(`/api/notification_read/${closedNotification.data._id}`, { 154 | method: 'POST', 155 | }) 156 | }) 157 | 158 | const registration = self.registration 159 | registration.onupdatefound = () => { 160 | const installingWorker = registration.installing 161 | if (installingWorker == null) { 162 | return 163 | } 164 | installingWorker.onstatechange = () => { 165 | if (installingWorker.state === 'activated') { 166 | // At this point, the updated precached content has been fetched, 167 | // window.location.reload(); 168 | const url = new URL('/', self.location.origin).href 169 | self.clients 170 | .matchAll({ 171 | type: 'window', 172 | includeUncontrolled: true, 173 | }) 174 | .then(async windowClients => { 175 | for (let client of windowClients) { 176 | await client.navigate(url) 177 | } 178 | }) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/serviceWorkerReg.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ) 22 | 23 | export function register(config) { 24 | // if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | if ('serviceWorker' in navigator) { 26 | // The URL constructor is available in all browsers that support SW. 27 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 28 | if (publicUrl.origin !== window.location.origin) { 29 | // Our service worker won't work if PUBLIC_URL is on a different origin 30 | // from what our page is served on. This might happen if a CDN is used to 31 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 32 | return 33 | } 34 | 35 | window.addEventListener('load', () => { 36 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 37 | // const swUrl = `${process.env.PUBLIC_URL}/custom-sw.js` 38 | 39 | if (isLocalhost) { 40 | // This is running on localhost. Let's check if a service worker still exists or not. 41 | checkValidServiceWorker(swUrl, config) 42 | 43 | // Add some additional logging to localhost, pointing developers to the 44 | // service worker/PWA documentation. 45 | navigator.serviceWorker.ready.then(() => { 46 | console.log( 47 | 'This web app is being served by service worker' 48 | ) 49 | }) 50 | } else { 51 | // Is not localhost. Just register service worker 52 | registerValidSW(swUrl, config) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | function registerValidSW(swUrl, config) { 59 | navigator.serviceWorker 60 | .register(swUrl) 61 | .then(registration => { 62 | registration.onupdatefound = () => { 63 | const installingWorker = registration.installing 64 | if (installingWorker == null) { 65 | return 66 | } 67 | installingWorker.onstatechange = () => { 68 | if (installingWorker.state === 'installed') { 69 | if (navigator.serviceWorker.controller) { 70 | // At this point, the updated precached content has been fetched, 71 | // but the previous service worker will still serve the older 72 | // content until all client tabs are closed. 73 | console.log( 74 | 'New content is available, reloading' 75 | ) 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration) 80 | } 81 | // window.location.reload() // done in service worker 82 | } else { 83 | // At this point, everything has been precached. 84 | // It's the perfect time to display a 85 | // "Content is cached for offline use." message. 86 | console.log('Content is cached for offline use.') 87 | 88 | // Execute callback 89 | if (config && config.onSuccess) { 90 | config.onSuccess(registration) 91 | } 92 | } 93 | } 94 | } 95 | } 96 | }) 97 | .catch(error => { 98 | console.error('Error during service worker registration:', error) 99 | }) 100 | } 101 | 102 | function checkValidServiceWorker(swUrl, config) { 103 | // Check if the service worker can be found. If not reload the page. 104 | fetch(swUrl, { 105 | headers: { 'Service-Worker': 'script' }, 106 | }) 107 | .then(response => { 108 | // Ensure service worker exists, and that we really are getting a JS file. 109 | const contentType = response.headers.get('content-type') 110 | if ( 111 | response.status === 404 || 112 | (contentType != null && contentType.indexOf('javascript') === -1) 113 | ) { 114 | // No service worker found. Probably a different app. Reload the page. 115 | navigator.serviceWorker.ready.then(registration => { 116 | registration.unregister().then(() => { 117 | window.location.reload() 118 | }) 119 | }) 120 | } else { 121 | // Service worker found. Proceed as normal. 122 | registerValidSW(swUrl, config) 123 | } 124 | }) 125 | .catch(() => { 126 | console.log( 127 | 'No internet connection found. App is running in offline mode.' 128 | ) 129 | }) 130 | } 131 | 132 | export function unregister() { 133 | if ('serviceWorker' in navigator) { 134 | navigator.serviceWorker.ready 135 | .then(registration => { 136 | registration.unregister() 137 | }) 138 | .catch(error => { 139 | console.error(error.message) 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/store/authSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' 2 | import { unsubscribeUser } from '../subscription' 3 | 4 | export const login = createAsyncThunk('auth/login', async (_, { dispatch }) => { 5 | let res = await fetch('/auth/login') 6 | if (res.ok) { 7 | let data = await res.json() 8 | if (data.user) { 9 | dispatch(loggedIn(data.user)) 10 | } 11 | } else if (res.status >= 500) { 12 | throw Error('Request Error') 13 | } else if (res.status >= 400) { 14 | dispatch(loggedOut()) 15 | } 16 | return null 17 | }) 18 | export const logout = createAsyncThunk('auth/logout', async (_, { dispatch }) => { 19 | unsubscribeUser() 20 | dispatch(loggedOut()) 21 | await fetch('/auth/logout', { 22 | method: 'POST', 23 | }) 24 | }) 25 | 26 | const authSlice = createSlice({ 27 | name: 'auth', 28 | initialState: { 29 | // isAuthenticated: false, 30 | isAuthenticated: !!sessionStorage.getItem('LOGGED_IN'), 31 | status: 'loading', //or "idle", "error" 32 | user: JSON.parse(sessionStorage.getItem('user')) || null, 33 | }, 34 | reducers: { 35 | loggedIn(state, action) { 36 | let user = action.payload 37 | state.isAuthenticated = true 38 | state.user = user 39 | sessionStorage.setItem('LOGGED_IN', '1') 40 | sessionStorage.setItem('user', JSON.stringify(user)) 41 | }, 42 | loggedOut(state) { 43 | state.isAuthenticated = false 44 | state.user = null 45 | sessionStorage.setItem('LOGGED_IN', '') 46 | sessionStorage.removeItem('user') 47 | }, 48 | userUpdated(state, action) { 49 | let user = action.payload 50 | state.user = user 51 | sessionStorage.setItem('user', JSON.stringify(user)) 52 | }, 53 | }, 54 | extraReducers: { 55 | [login.rejected]: state => { 56 | state.status = 'error' 57 | }, 58 | [login.pending]: state => { 59 | state.status = 'loading' 60 | }, 61 | [login.fulfilled]: state => { 62 | state.status = 'idle' 63 | }, 64 | }, 65 | }) 66 | 67 | let { actions, reducer } = authSlice 68 | export const { loggedIn, loggedOut, userUpdated } = actions 69 | export default reducer 70 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit' 2 | import postsReducer from 'features/posts/postsSlice' 3 | import searchReducer from 'features/search/searchSlice' 4 | import trendsReducer from 'features/trends/trendsSlice' 5 | import usersReducer from 'features/users/usersSlice' 6 | import notifyReducer from 'features/notify/notifySlice' 7 | import authReducer from './authSlice' 8 | 9 | const store = configureStore({ 10 | reducer: { 11 | posts: postsReducer, 12 | search: searchReducer, 13 | trends: trendsReducer, 14 | users: usersReducer, 15 | notify: notifyReducer, 16 | auth: authReducer 17 | }, 18 | middleware: [...getDefaultMiddleware({ immutableCheck: false })] 19 | }) 20 | 21 | export default store -------------------------------------------------------------------------------- /src/styles/custom.scss: -------------------------------------------------------------------------------- 1 | /* make the customizations */ 2 | $primary: #3eaaee; 3 | // $secondary: rgb(26, 145, 218); 4 | // $primary: #55afb0; 5 | $dark: rgb(20, 23, 26); 6 | $light: rgb(241, 241, 241); 7 | // $dark: rgb(64, 64, 64); 8 | // $dark: rgb(53, 83, 87); 9 | $body-color: $dark; 10 | // $bg-color: rgb(245, 248, 250); 11 | $bg-color: lighten($primary, 39.5%); 12 | $text-muted: #657786; 13 | 14 | $card-bg: $bg-color; 15 | $card-border-radius: 1em; 16 | $card-border-width: 0; 17 | $card-cap-bg: $card-bg; 18 | $list-group-bg: transparent; 19 | $list-group-hover-bg: lighten($primary, 40%); 20 | $list-group-action-active-bg: lighten($primary, 39%); 21 | 22 | $input-bg: $bg-color; 23 | $input-border-width: 0; 24 | $input-btn-focus-width: 0; 25 | $input-padding-y: 0.15rem; 26 | $input-padding-x: 0.15rem; 27 | $input-font-size: 1.1em; 28 | $input-font-size-sm: 0.95em; 29 | $input-border-radius: 0; 30 | $input-group-addon-border-width: 0; 31 | 32 | $popover-border-width: 0; 33 | $popover-border-radius: 1em; 34 | $popover-max-width: 325px; 35 | $popover-min-width: 275px; 36 | 37 | $modal-content-border-width: 0; 38 | $modal-content-border-radius: 1em; 39 | $modal-header-border-width: 1px; 40 | $modal-dialog-margin-y-sm-up: 3em; 41 | $modal-dialog-margin: 0; 42 | $modal-sm: 375px; 43 | $modal-md: 550px; 44 | $modal-lg: 650px; 45 | 46 | $border-radius: 1em; 47 | 48 | $alert-border-radius: 0; 49 | $alert-color-level: -10; 50 | $alert-bg-level: -2; 51 | 52 | $progress-height: 3px; 53 | 54 | $badge-font-weight: 400; 55 | $badge-font-size: 0.7em; 56 | $badge-padding-y: 0.15em; 57 | $badge-padding-x: 0.35em; 58 | 59 | $btn-font-size-lg: 1.15rem; 60 | 61 | $btn-disabled-opacity: 0.5; 62 | 63 | $font-weight-bolder: 800; 64 | $overflows: auto, hidden, visible; 65 | $grid-gutter-width: 0; 66 | $enable-responsive-font-sizes: true; 67 | $container-max-widths: ( 68 | sm: 570px, 69 | md: 720px, 70 | lg: 990px, 71 | xl: 1200px, 72 | ); 73 | $theme-colors: ( 74 | "black": black, 75 | "border-color": rgb(222, 226, 230), 76 | "bg-color": $bg-color, 77 | ); 78 | 79 | $spinner-width: 1.5rem; 80 | $spinner-border-width: 0.15em; 81 | 82 | $line-height-base: 1.3; 83 | 84 | // $link-color: blue; 85 | 86 | /* import bootstrap to set changes */ 87 | @import "~bootstrap/scss/bootstrap"; 88 | 89 | $input-group-addon-bg: $gray-300; 90 | 91 | @each $color, $value in $theme-colors { 92 | .btn-outline-#{$color} { 93 | @include button-variant(white, $value, lighten($value, 40%), $value, lighten($value, 37.5%), $value); 94 | color: $value !important; 95 | } 96 | .btn-outline { 97 | @include button-variant(white, $value, lighten($value, 40%), $value, lighten($value, 37.5%), $value); 98 | } 99 | // custom button with no outline or fill 100 | .btn-naked-#{$color} { 101 | @include button-variant(white, transparent, lighten($value, 38%), transparent, lighten($value, 37.5%)); 102 | border: none; 103 | background: transparent; 104 | &:hover { 105 | color: $value; 106 | } 107 | &:focus:not(:hover) { 108 | // color: $value; 109 | background: transparent; 110 | } 111 | &:not(:disabled):not(.disabled):active { 112 | color: $value; 113 | } 114 | &:not(:disabled):not(.disabled).active { 115 | color: $value; 116 | } 117 | &:not(:disabled):not(.disabled):not(:hover).active { 118 | background-color: transparent; 119 | } 120 | } 121 | } 122 | @each $value in $overflows { 123 | .overflow-y-#{$value} { 124 | overflow-y: $value !important; 125 | } 126 | .overflow-x-#{$value} { 127 | overflow-x: $value !important; 128 | } 129 | } 130 | 131 | // // Option B: Include parts of Bootstrap 132 | 133 | // // Required 134 | // @import "~bootstrap/scss/functions"; 135 | // @import "~bootstrap/scss/variables"; 136 | // @import "~bootstrap/scss/mixins"; 137 | 138 | // // Optional 139 | // @import "~bootstrap/scss/reboot"; 140 | // @import "~bootstrap/scss/type"; 141 | // @import "~bootstrap/scss/images"; 142 | // @import "~bootstrap/scss/code"; 143 | // @import "~bootstrap/scss/grid"; 144 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import "custom"; 2 | 3 | html { 4 | font-size: 16px; 5 | } 6 | body { 7 | overflow-y: scroll; 8 | overflow-x: hidden; 9 | } 10 | a { 11 | color: $primary; 12 | text-decoration: none; 13 | & img:hover, 14 | & figure:hover { 15 | box-shadow: 0px 0px 5px 0px var(--primary); 16 | } 17 | svg { 18 | float: left; 19 | } 20 | } 21 | .btn { 22 | font-family: inherit; 23 | // font-weight: inherit; 24 | // font-size: inherit; 25 | padding: 0.5em; 26 | > svg { 27 | float: left; 28 | } 29 | } 30 | button { 31 | font-family: inherit; 32 | // font-size: inherit; 33 | // font-weight: inherit; 34 | > span { 35 | margin: auto; 36 | } 37 | } 38 | textarea { 39 | resize: none; 40 | font: inherit; 41 | border: none; 42 | background-color: transparent; 43 | outline: none; 44 | font-size: 1.25em; 45 | font-weight: 400; 46 | &:active:focus:hover { 47 | background-color: transparent; 48 | outline: none; 49 | border: none; 50 | } 51 | } 52 | input { 53 | border: none; 54 | color: $dark; 55 | background-color: transparent; 56 | font-family: inherit; 57 | &:active:focus { 58 | outline: none; 59 | border: none; 60 | } 61 | } 62 | a { 63 | white-space: nowrap; 64 | } 65 | .hide-scroll { 66 | /* This is the magic bit for Firefox */ 67 | scrollbar-width: none; 68 | &::-webkit-scrollbar { 69 | /* This is the magic bit for WebKit */ 70 | display: none; 71 | } 72 | } 73 | .message { 74 | width: 100%; 75 | color: $info; 76 | display: flex; 77 | justify-content: center; 78 | font-size: 0.9em; 79 | padding: 1em 0; 80 | } 81 | .list-group { 82 | max-width: 600px; 83 | } 84 | .form-group { 85 | background-color: $input-bg; 86 | border-bottom: 2px solid $gray-600; 87 | padding: 0.25em; 88 | } 89 | label.form-label { 90 | margin-bottom: 0; 91 | color: $text-muted; 92 | font-size: 0.95em; 93 | } 94 | input[type="password"] { 95 | background-color: darken($input-bg, 5%) !important; 96 | } 97 | .card-header { 98 | font-weight: 800; 99 | font-size: 1.25em; 100 | border-bottom: 1px solid $border-color; 101 | } 102 | .card-footer { 103 | border-top: 1px solid $border-color !important; 104 | } 105 | .bg-clear { 106 | background-color: transparent !important; 107 | } 108 | a:not(.stretched-link) { 109 | z-index: 500; 110 | } 111 | .high-index { 112 | z-index: 1000; 113 | } 114 | .higher-index { 115 | z-index: 2000; 116 | } 117 | @keyframes slide { 118 | 0% { 119 | opacity: 0; 120 | transform: translateY(-50px); 121 | } 122 | 100% { 123 | opacity: 1; 124 | transform: translateY(0); 125 | } 126 | } 127 | .list-group-item { 128 | animation: slide 0.15s ease-out; 129 | } 130 | blockquote { 131 | z-index: 1; 132 | font: inherit; 133 | white-space: pre-wrap !important; 134 | word-break: break-word !important; 135 | overflow-wrap: break-word !important; 136 | } 137 | #user-popover { 138 | box-shadow: 0 0 1rem rgba($dark, 0.35); 139 | } 140 | @media (max-width: 576px) { 141 | .modal-dialog.modal-lg { 142 | max-width: $modal-lg; 143 | margin: 0 auto; 144 | height: 100vh; 145 | .modal-content { 146 | border-radius: 0; 147 | height: 100%; 148 | max-height: 100%; 149 | min-height: 0; 150 | } 151 | } 152 | } 153 | .modal-sm { 154 | max-width: $modal-sm; 155 | max-height: calc(100% - 5em); 156 | margin: auto; 157 | height: 100%; 158 | .modal-content { 159 | margin: auto 1em; 160 | height: fit-content; 161 | } 162 | } 163 | .popover { 164 | min-width: $popover-min-width; 165 | } 166 | a + span.badge { 167 | left: -10px; 168 | top: -7.5px; 169 | // content: attr(data-count); 170 | } 171 | .border-left-right-primary-custom { 172 | border-left: 1px solid $primary !important; 173 | border-right: 1px solid $primary !important; 174 | } 175 | .border-left-right-secondary-custom { 176 | border-left: 1px solid $secondary !important; 177 | border-right: 1px solid $secondary !important; 178 | } 179 | .break-all { 180 | word-break: break-all; 181 | } 182 | -------------------------------------------------------------------------------- /src/subscription.js: -------------------------------------------------------------------------------- 1 | const convertedVapidKey = urlBase64ToUint8Array(process.env.REACT_APP_PUBLIC_VAPID_KEY) 2 | 3 | /** 4 | * conforms to both Apis (promise or callback) 5 | */ 6 | export function askPermission() { 7 | return new Promise(function (resolve, reject) { 8 | const permissionResult = Notification.requestPermission(function (result) { 9 | resolve(result) 10 | }) 11 | if (permissionResult) { 12 | permissionResult.then(resolve, reject) 13 | } 14 | }).then(result => { 15 | if (result !== 'granted') { 16 | alert('Notification permission denied\nIf it was by mistake, turn it on from the settings') 17 | return false 18 | } 19 | return true 20 | }) 21 | } 22 | 23 | export function subscribeUserToPush() { 24 | if ('serviceWorker' in navigator) { 25 | navigator.serviceWorker.ready.then(function (registration) { 26 | if (!registration.pushManager) { 27 | console.warn('Push manager unavailable.') 28 | return 29 | } 30 | 31 | registration.pushManager.getSubscription().then(function (existingSubscription) { 32 | if (existingSubscription === null) { 33 | console.info('No push subscription detected. Making one..') 34 | const subscribeOptions = { 35 | userVisibleOnly: true, 36 | applicationServerKey: convertedVapidKey, 37 | } 38 | registration.pushManager 39 | .subscribe(subscribeOptions) 40 | .then(function (newSubscription) { 41 | console.log('New push subscription added.') 42 | return sendSubscription(newSubscription) 43 | }) 44 | .catch(function (e) { 45 | if (Notification.permission !== 'granted') { 46 | console.info('Permission was not granted.') 47 | } else { 48 | console.error( 49 | 'An error ocurred during the subscription process.', 50 | e 51 | ) 52 | } 53 | }) 54 | } else { 55 | console.info('Existing subscription detected.') 56 | return sendSubscription(existingSubscription) 57 | } 58 | }) 59 | }) 60 | } 61 | } 62 | export function unsubscribeUser() { 63 | removeSubscription().then(function () { 64 | navigator.serviceWorker.ready.then(function (registration) { 65 | registration.pushManager 66 | .getSubscription() 67 | .then(function (subscription) { 68 | if (subscription) { 69 | return subscription.unsubscribe() 70 | } 71 | }) 72 | .catch(function (error) { 73 | console.error('Error unsubscribing', error) 74 | }) 75 | .then(function () { 76 | console.info('User is unsubscribed.') 77 | }) 78 | }) 79 | }) 80 | } 81 | function removeSubscription() { 82 | return fetch('/api/notifications/unsubscribe', { 83 | method: 'POST', 84 | }) 85 | } 86 | 87 | function sendSubscription(subscription) { 88 | return fetch('/api/notifications/subscribe', { 89 | method: 'POST', 90 | body: JSON.stringify(subscription), 91 | headers: { 92 | 'Content-Type': 'application/json', 93 | }, 94 | }) 95 | } 96 | 97 | function urlBase64ToUint8Array(base64String) { 98 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4) 99 | // eslint-disable-next-line 100 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') 101 | 102 | const rawData = window.atob(base64) 103 | const outputArray = new Uint8Array(rawData.length) 104 | 105 | for (let i = 0; i < rawData.length; ++i) { 106 | outputArray[i] = rawData.charCodeAt(i) 107 | } 108 | return outputArray 109 | } 110 | -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | /** 3 | * truncates text after n newlines 4 | * @param {String} text to trunaate 5 | * @param {Number} lines number of lines to have 6 | */ 7 | export const truncateText = (text, lines) => { 8 | if (!text) 9 | return '' 10 | let n = 0, i = 0 11 | let length = text.length 12 | for (i = 0; i < length; i++) 13 | if (text[i] === '\n') 14 | if (n++ >= lines - 1) 15 | break 16 | return text.slice(0, i) + ((length > i + 1) ? ' ...' : '') 17 | } 18 | 19 | export function numFormatter(num) { 20 | if (num >= 1000 && num < 1000000) { 21 | return (num / 1000).toFixed(2) + 'K'; // convert to K for number from > 1000 < 1 million 22 | } else if (num >= 1000000) { 23 | return (num / 1000000).toFixed(1) + 'M'; // convert to M for number from > 1 million 24 | } else if (num < 1000) { 25 | return num; // if value < 1000, nothing to do 26 | } 27 | } 28 | 29 | /** 30 | * @returns input if good 31 | * @throws {Error} with msg 'message for front-end'} 32 | * @param {String} input - input to sanitize 33 | * @param type - one of 'name', 'username', 'password', 'html', 'custom' 34 | * @param {Object} opts optional setings with sig { min_length, max_length, regex } 35 | */ 36 | export function filterInput(input = '', type = 'custom', { 37 | min_length: min = 1, 38 | max_length: max = 70, 39 | regex: reg = null, 40 | identifier = null 41 | } = {}) { 42 | identifier = identifier || `input {${type}}` 43 | input = input.toString().trim() 44 | let regexes = { 45 | username: RegExp(`^[_a-zA-Z0-9]{${min},${max}}$`), 46 | password: RegExp(`^\\S{${min},${max}}$`), 47 | name: RegExp(`^.{${min},${max}}$`), 48 | } 49 | if (!reg) { 50 | reg = regexes[type] 51 | } 52 | if (reg) { 53 | if (!reg.test(input)) { 54 | throw Error(`${identifier} must match regex: ${reg} (range between ${min} and ${max} characters)`) 55 | } 56 | } 57 | //else custom || html 58 | if (type === 'html') 59 | input = DOMPurify.sanitize(input, { ALLOWED_TAGS: ['b'] }).trim() 60 | if (input.length > max || input.length < min) { 61 | throw Error(`${identifier} must be minimum ${min} and maximum ${max} characters`) 62 | } 63 | if (input.includes('\n')) // long text, strip of multiple newlines etc 64 | input = input.replace(/\n+/g, '\n').trim() 65 | return input; 66 | } --------------------------------------------------------------------------------
{" - "}
84 | 85 |
{user.name}
@{user.screen_name}
{truncateText(user.description, 5)}
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 |
{body || "Please confirm you action to proceed or click anywhere outside or Esc or button below to cancel"}
43 | 44 |
{truncateText(user.description, 7)}
63 | @{user.screen_name} mentioned you in post 64 |
66 | 67 |
76 | @{user.screen_name} replied 77 |
87 | @{user.screen_name} liked 88 |
98 | @{user.screen_name} started following you 99 |
108 | @{user.screen_name} no longer follows you 109 |
118 | @{user.screen_name} reposted 119 |
79 | 80 |
{itm.name}
{user.description}
103 | {/* Forgot password? 104 | */} 105 | {this.state.error} 106 |
94 | Already have account? login instead 95 | 96 | {this.state.error} 97 |