├── .gitignore ├── .prettierrc ├── README.md ├── images └── demo-screenshot.png ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── components ├── FollowBtn.js ├── Home │ ├── CreateTweetTop.js │ ├── HomeContent.js │ ├── MainHeader.js │ └── Timeline.js ├── Icons.zip ├── Icons │ ├── ArrowLeft.js │ ├── BarChart.js │ ├── Bell.js │ ├── Bookmark.js │ ├── Calendar.js │ ├── Close.js │ ├── Comment.js │ ├── Emoji.js │ ├── Gif.js │ ├── Group.js │ ├── Hashtag.js │ ├── Heart.js │ ├── Home.js │ ├── Image.js │ ├── Location.js │ ├── Mail.js │ ├── More.js │ ├── Poll.js │ ├── ProgressRing.js │ ├── Retweet.js │ ├── Search.js │ ├── Star.js │ ├── Twitter.js │ ├── Upload.js │ └── User.js ├── Layout.js ├── LeftSide.js ├── LoadingIndicator.js ├── Modal.js ├── Notification │ ├── CommentNotification.js │ ├── FollowNotification.js │ ├── LikeNotification.js │ ├── NotificationContent.js │ └── NotificationGroup.js ├── Profile │ ├── ProfileBio.js │ ├── ProfileContent.js │ ├── ProfileHeader.js │ ├── ProfileTweets.js │ └── TabList.js ├── RightSide.js ├── ScrollToTop.js ├── Thread │ ├── ThreadContent.js │ ├── ThreadHeader.js │ ├── TweetCommentBlock.js │ └── TweetContent.js └── Tweet │ ├── CommentDialog.js │ ├── CreateTweetDialog.js │ ├── TweetActorName.js │ ├── TweetBlock.js │ └── TweetForm.js ├── hooks ├── useComment.js ├── useFollow.js ├── useLike.js ├── useNotification.js └── useTweet.js ├── index.css ├── index.js ├── logo.svg ├── pages ├── HomePage.js ├── Notifications.js ├── Profile.js ├── StartPage.js └── Thread.js ├── reportWebVitals.js ├── setupTests.js ├── users.js └── utils ├── links.js ├── storage.js └── string.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 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamer 2 | 3 | streamer is a Twitter Clone built with Stream Feeds and the React Feeds SDK: 4 | 5 | ![Preview of built clone](./images/demo-screenshot.png) 6 | 7 | ## Quick Links 8 | 9 | - [Register](https://getstream.io/chat/trial/) to get an API key for your Stream App 10 | - [React Feeds SDK](https://github.com/GetStream/react-activity-feed) 11 | - [React Feeds Tutorial](https://getstream.io/react-activity-feed/tutorial/) 12 | 13 | ## What is Stream? 14 | 15 | Stream allows developers to rapidly deploy scalable feeds and chat messaging with an industry-leading 99.999% uptime SLA guarantee. With Stream’s chat components, developers can quickly add chat to their app for a variety of use-cases: 16 | 17 | - Livestreams like Twitch or Youtube 18 | - In-Game chat like Overwatch or Fortnite 19 | - Team style chat like Slack 20 | - Messaging style chat like Whatsapp or Facebook’s messenger 21 | - Commerce chat like Drift or Intercom 22 | 23 | ## Repo Overview 😎 24 | 25 | This repo contains full source code for the [Build a Twitter Clone](https://getstream.io/blog/build-twitter-clone/) tutorial series on Stream's blog. 26 | 27 | Supported functionalities in this clone include: 28 | 29 | - Sign in using different user accounts 30 | - Create tweets 31 | - React to tweets (like and comments) 32 | - Follow users 33 | - Notifications for reactions and follows 34 | 35 | ## Requirements 🛠 36 | 37 | - [Register](https://getstream.io/chat/trial/) and create a Stream app (you can call it streamer or whatever you want) 38 | - [Install Node v16.13.1](https://nodejs.org/ru/blog/release/v16.13.1/) (16.13.1 is the version used for this project) 39 | 40 | ## Steps to Run Locally 🧑‍💻👩‍💻 41 | 42 | - Clone this repo: 43 | 44 | ```bash 45 | git clone https://github.com/GetStream/react-twitter-clone 46 | ``` 47 | 48 | - Install dependencies: 49 | 50 | ```bash 51 | npm install 52 | ## or 53 | yarn 54 | ``` 55 | 56 | - Add the **API key** and **APP ID** of your Stream app to [src/App.js, line 15 and 16](./src/App.js#L15) 57 | 58 | - Using your **API key** and **API Secret**, [generate user tokens](https://generator.getstream.io/) for all users in [users.js](./src/users.js) and replace the token property accordinly 59 | 60 | - [Create three feed groups](https://getstream.io/activity-feeds/docs/node/creating_feeds/) on your Stream dashboard for your app: 61 | - **timeline** feed group of flat type 62 | - **user** feed group of flat type 63 | - **notification** feed group of notification type 64 | 65 | - Start server: 66 | 67 | ```bash 68 | npm run start 69 | ``` 70 | 71 | Your Twitter clone will be live on `localhost:3000` and you can begin experimenting the functionalities or adding yours 😁 72 | 73 | Kindly leave a star on the [React Feeds SDK](https://github.com/GetStream/react-activity-feed) if you enjoyed the result of the tutorial 😁 74 | -------------------------------------------------------------------------------- /images/demo-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/react-twitter-clone/c16cd5cfe9d4e87aeb720ea965e9c37df866978d/images/demo-screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-twitter-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.4", 7 | "@testing-library/react": "^13.2.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "classnames": "^2.3.1", 10 | "date-fns": "^2.28.0", 11 | "getstream": "^8.0.1", 12 | "nanoid": "^3.3.4", 13 | "react": "^18.1.0", 14 | "react-activity-feed": "^1.4.0", 15 | "react-dom": "^18.1.0", 16 | "react-router-dom": "^6.3.0", 17 | "react-scripts": "5.0.1", 18 | "styled-components": "^5.3.5", 19 | "web-vitals": "^2.1.4" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/react-twitter-clone/c16cd5cfe9d4e87aeb720ea965e9c37df866978d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/react-twitter-clone/c16cd5cfe9d4e87aeb720ea965e9c37df866978d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/react-twitter-clone/c16cd5cfe9d4e87aeb720ea965e9c37df866978d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 2 | import { useEffect, useState } from 'react' 3 | import { StreamClient } from 'getstream' 4 | import { StreamApp } from 'react-activity-feed' 5 | 6 | import StartPage from './pages/StartPage' 7 | import users from './users' 8 | import { getFromStorage } from './utils/storage' 9 | import ScrollToTop from './components/ScrollToTop' 10 | import HomePage from './pages/HomePage' 11 | import Profile from './pages/Profile' 12 | import Thread from './pages/Thread' 13 | import Notifications from './pages/Notifications' 14 | 15 | const APP_ID = '1183905' 16 | const API_KEY = 'mx8gc4kmvpec' 17 | 18 | export default function App() { 19 | const userId = getFromStorage('user') 20 | 21 | const user = users.find((u) => u.id === userId) || users[0] 22 | 23 | const [client, setClient] = useState(null) 24 | 25 | useEffect(() => { 26 | async function init() { 27 | const client = new StreamClient(API_KEY, user.token, APP_ID) 28 | await client.user(user.id).getOrCreate({ ...user, token: '' }) 29 | 30 | setClient(client) 31 | } 32 | 33 | init() 34 | }, []) 35 | 36 | if (!client) return <> 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | } /> 44 | } path="/home" /> 45 | } path="/:user_id" /> 46 | } path="/:user_id/status/:id" /> 47 | } path="/notifications" /> 48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/FollowBtn.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import styled from 'styled-components' 3 | 4 | import useFollow from '../hooks/useFollow' 5 | 6 | const Container = styled.div` 7 | button { 8 | text-align: center; 9 | padding: 0 15px; 10 | font-size: 13px; 11 | font-weight: bold; 12 | cursor: pointer; 13 | width: 100px; 14 | height: 30px; 15 | border-radius: 30px; 16 | 17 | &.following { 18 | color: white; 19 | background-color: transparent; 20 | border: 1px solid #666; 21 | } 22 | 23 | &.not-following { 24 | background-color: #ccc; 25 | width: 80px; 26 | color: black; 27 | } 28 | 29 | .follow-text { 30 | &__unfollow { 31 | display: none; 32 | } 33 | &__following { 34 | display: block; 35 | } 36 | } 37 | 38 | &:hover { 39 | &.following { 40 | color: red; 41 | border-color: red; 42 | 43 | .follow-text { 44 | &__unfollow { 45 | display: block; 46 | } 47 | &__following { 48 | display: none; 49 | } 50 | } 51 | } 52 | } 53 | } 54 | ` 55 | 56 | export default function FollowBtn({ userId }) { 57 | const { isFollowing, toggleFollow } = useFollow({ userId }) 58 | 59 | return ( 60 | 61 | 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/components/Home/CreateTweetTop.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import useTweet from '../../hooks/useTweet' 4 | import TweetForm from '../Tweet/TweetForm' 5 | 6 | const Container = styled.div` 7 | padding: 15px; 8 | ` 9 | 10 | export default function CreateTweetTop() { 11 | const { createTweet } = useTweet() 12 | 13 | const onSubmit = async (text) => { 14 | createTweet(text) 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Home/HomeContent.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Feed, useStreamContext } from 'react-activity-feed' 3 | 4 | import CreateTweetTop from './CreateTweetTop' 5 | import MainHeader from './MainHeader' 6 | import Timeline from '../Home/Timeline' 7 | import LoadingIndicator from '../LoadingIndicator' 8 | 9 | const Container = styled.div` 10 | .header { 11 | position: sticky; 12 | top: 0; 13 | z-index: 1; 14 | } 15 | 16 | .create-tweet-top { 17 | border-bottom: 1px solid #333; 18 | } 19 | 20 | .new-tweets-info { 21 | border-bottom: 1px solid #333; 22 | padding: 20px; 23 | text-align: center; 24 | color: var(--theme-color); 25 | display: block; 26 | width: 100%; 27 | font-size: 16px; 28 | 29 | &:hover { 30 | background: #111; 31 | } 32 | } 33 | ` 34 | 35 | export default function HomeContent() { 36 | const { client } = useStreamContext() 37 | 38 | const user = client.currentUser.data 39 | 40 | if (!user) 41 | return ( 42 | 43 | 44 | 45 | ) 46 | 47 | return ( 48 | 49 |
50 | 51 |
52 | 53 |
54 | 55 |
56 | 57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Home/MainHeader.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import Star from '../Icons/Star' 4 | 5 | const Header = styled.header` 6 | display: flex; 7 | align-items: center; 8 | padding: 15px; 9 | color: white; 10 | width: 100%; 11 | font-weight: bold; 12 | justify-content: space-between; 13 | backdrop-filter: blur(2px); 14 | background-color: rgba(0, 0, 0, 0.5); 15 | 16 | h1 { 17 | font-size: 20px; 18 | } 19 | ` 20 | 21 | export default function MainHeader() { 22 | return ( 23 |
24 |

Home

25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Home/Timeline.js: -------------------------------------------------------------------------------- 1 | import { FlatFeed, useStreamContext } from 'react-activity-feed' 2 | 3 | import TweetBlock from '../Tweet/TweetBlock' 4 | 5 | export default function Timeline() { 6 | const { user } = useStreamContext() 7 | 8 | return ( 9 |
10 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Icons.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/react-twitter-clone/c16cd5cfe9d4e87aeb720ea965e9c37df866978d/src/components/Icons.zip -------------------------------------------------------------------------------- /src/components/Icons/ArrowLeft.js: -------------------------------------------------------------------------------- 1 | export default function ArrowLeft({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/BarChart.js: -------------------------------------------------------------------------------- 1 | export default function BarChart({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Bell.js: -------------------------------------------------------------------------------- 1 | export default function Bell({ color = 'block', size = 18, fill = false }) { 2 | if (fill) 3 | return ( 4 | 11 | 12 | 13 | 14 | ) 15 | 16 | return ( 17 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Icons/Bookmark.js: -------------------------------------------------------------------------------- 1 | export default function Bookmark({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Calendar.js: -------------------------------------------------------------------------------- 1 | export default function Calendar({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Close.js: -------------------------------------------------------------------------------- 1 | export default function Close({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Comment.js: -------------------------------------------------------------------------------- 1 | export default function Comment({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Emoji.js: -------------------------------------------------------------------------------- 1 | export default function Emoji({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Gif.js: -------------------------------------------------------------------------------- 1 | export default function Gif({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Group.js: -------------------------------------------------------------------------------- 1 | export default function Group({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Hashtag.js: -------------------------------------------------------------------------------- 1 | export default function Hashtag({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Heart.js: -------------------------------------------------------------------------------- 1 | export default function Heart({ color = 'block', size = 18, fill = false }) { 2 | if (fill) 3 | return ( 4 | 11 | 12 | 13 | 14 | ) 15 | 16 | return ( 17 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Icons/Home.js: -------------------------------------------------------------------------------- 1 | export default function Home({ color = 'block', size = 18, fill = false }) { 2 | if (fill) 3 | return ( 4 | 11 | 12 | 13 | 14 | ) 15 | 16 | return ( 17 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Icons/Image.js: -------------------------------------------------------------------------------- 1 | export default function Image({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Location.js: -------------------------------------------------------------------------------- 1 | export default function Location({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Mail.js: -------------------------------------------------------------------------------- 1 | export default function Mail({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/More.js: -------------------------------------------------------------------------------- 1 | export default function More({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Poll.js: -------------------------------------------------------------------------------- 1 | export default function Poll({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/ProgressRing.js: -------------------------------------------------------------------------------- 1 | export default function ProgressRing({ radius, stroke, progress, color }) { 2 | const normalizedRadius = radius - stroke * 2 3 | const circumference = normalizedRadius * 2 * Math.PI 4 | const strokeDashoffset = circumference - (progress / 100) * circumference 5 | 6 | return ( 7 | 12 | 21 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Icons/Retweet.js: -------------------------------------------------------------------------------- 1 | export default function Retweet({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Search.js: -------------------------------------------------------------------------------- 1 | export default function Search({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Star.js: -------------------------------------------------------------------------------- 1 | export default function Star({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Twitter.js: -------------------------------------------------------------------------------- 1 | export default function Twitter({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/Upload.js: -------------------------------------------------------------------------------- 1 | export default function Upload({ color = 'block', size = 18 }) { 2 | return ( 3 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/User.js: -------------------------------------------------------------------------------- 1 | export default function User({ color = 'block', size = 18, fill = false }) { 2 | if (fill) 3 | return ( 4 | 11 | 12 | 13 | 14 | ) 15 | 16 | return ( 17 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Layout.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useStreamContext } from 'react-activity-feed' 3 | import styled from 'styled-components' 4 | 5 | import LeftSide from './LeftSide' 6 | import RightSide from './RightSide' 7 | import CreateTweetDialog from './Tweet/CreateTweetDialog' 8 | import LoadingIndicator from './LoadingIndicator' 9 | 10 | const Container = styled.div` 11 | min-height: 100vh; 12 | background: black; 13 | --left: 300px; 14 | --right: 400px; 15 | --middle: calc(100% - var(--left) - var(--right)); 16 | 17 | .content { 18 | max-width: 1300px; 19 | margin: 0 auto; 20 | width: 100%; 21 | display: flex; 22 | } 23 | 24 | .left-side-bar { 25 | height: 100vh; 26 | width: var(--left); 27 | position: sticky; 28 | top: 0; 29 | } 30 | 31 | .main-content { 32 | position: relative; 33 | width: var(--middle); 34 | border-left: 1px solid #333; 35 | border-right: 1px solid #333; 36 | min-height: 100vh; 37 | } 38 | 39 | .right-side-bar { 40 | width: var(--right); 41 | } 42 | ` 43 | 44 | export default function Layout({ children }) { 45 | const { user } = useStreamContext() 46 | 47 | const [createDialogOpened, setCreateDialogOpened] = useState(false) 48 | 49 | if (!user) return 50 | 51 | return ( 52 | <> 53 | {createDialogOpened && ( 54 | setCreateDialogOpened(false)} 56 | /> 57 | )} 58 | 59 |
60 |
61 | setCreateDialogOpened(true)} /> 62 |
63 |
64 | {!user ? : children} 65 |
66 |
67 | 68 |
69 |
70 |
71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/LeftSide.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useEffect, useState } from 'react' 3 | import { useStreamContext } from 'react-activity-feed' 4 | import { Link, useLocation } from 'react-router-dom' 5 | import styled from 'styled-components' 6 | 7 | import LoadingIndicator from './LoadingIndicator' 8 | import Bell from './Icons/Bell' 9 | import Group from './Icons/Group' 10 | import Home from './Icons/Home' 11 | import Hashtag from './Icons/Hashtag' 12 | import Mail from './Icons/Mail' 13 | import Bookmark from './Icons/Bookmark' 14 | import User from './Icons/User' 15 | import More from './Icons/More' 16 | import Twitter from './Icons/Twitter' 17 | 18 | const Container = styled.div` 19 | display: flex; 20 | flex-direction: column; 21 | padding: 0 30px; 22 | height: 100%; 23 | 24 | .header { 25 | padding: 15px; 26 | } 27 | 28 | .buttons { 29 | margin-top: 5px; 30 | max-width: 200px; 31 | 32 | a, 33 | button { 34 | display: block; 35 | margin-bottom: 12px; 36 | color: white; 37 | padding: 10px 15px; 38 | display: flex; 39 | align-items: center; 40 | border-radius: 30px; 41 | font-size: 18px; 42 | padding-right: 25px; 43 | text-decoration: none; 44 | --icon-size: 25px; 45 | 46 | .btn--icon { 47 | margin-right: 15px; 48 | height: var(--icon-size); 49 | width: var(--icon-size); 50 | 51 | position: relative; 52 | .notifications-count { 53 | position: absolute; 54 | font-size: 11px; 55 | /* min-width: 14px; */ 56 | background-color: var(--theme-color); 57 | top: -5px; 58 | padding: 1px 5px; 59 | border-radius: 10px; 60 | left: 0; 61 | right: 0; 62 | margin: 0 auto; 63 | width: max-content; 64 | } 65 | } 66 | 67 | &.active { 68 | font-weight: bold; 69 | 70 | img { 71 | --size: 27px; 72 | } 73 | } 74 | 75 | &:hover { 76 | background-color: #333; 77 | } 78 | 79 | &.btn--home { 80 | position: relative; 81 | &.new-tweets::after { 82 | content: ''; 83 | position: absolute; 84 | width: 5px; 85 | height: 5px; 86 | left: 35px; 87 | top: 7px; 88 | border-radius: 50%; 89 | background-color: var(--theme-color); 90 | } 91 | } 92 | 93 | &.btn--more { 94 | svg { 95 | border: 1px solid #fff; 96 | border-radius: 50%; 97 | display: flex; 98 | align-items: center; 99 | justify-content: center; 100 | } 101 | } 102 | } 103 | } 104 | 105 | .tweet-btn { 106 | background-color: var(--theme-color); 107 | margin-top: 10px; 108 | border-radius: 30px; 109 | color: white; 110 | text-align: center; 111 | padding: 15px 0; 112 | font-size: 16px; 113 | } 114 | 115 | .profile-section { 116 | margin-top: auto; 117 | margin-bottom: 20px; 118 | padding: 10px; 119 | display: flex; 120 | text-align: left; 121 | align-items: center; 122 | justify-content: space-between; 123 | border-radius: 30px; 124 | 125 | &:hover { 126 | background-color: #333; 127 | } 128 | 129 | .details { 130 | display: flex; 131 | align-items: center; 132 | &__img { 133 | margin-right: 10px; 134 | width: 40px; 135 | border-radius: 50%; 136 | height: 40px; 137 | overflow: hidden; 138 | 139 | img { 140 | width: 100%; 141 | height: 100%; 142 | } 143 | } 144 | 145 | &__text { 146 | span { 147 | display: block; 148 | } 149 | 150 | &__name { 151 | color: white; 152 | font-size: 16px; 153 | font-weight: bold; 154 | } 155 | 156 | &__id { 157 | font-size: 14px; 158 | margin-top: 2px; 159 | color: #aaa; 160 | } 161 | } 162 | } 163 | } 164 | ` 165 | 166 | export default function LeftSide({ onClickTweet }) { 167 | const location = useLocation() 168 | const { client, userData } = useStreamContext() 169 | 170 | const [newNotifications, setNewNotifications] = useState(0) 171 | 172 | useEffect(() => { 173 | if (!userData || location.pathname === `/notifications`) return 174 | 175 | let notifFeed 176 | 177 | async function init() { 178 | notifFeed = client.feed('notification', userData.id) 179 | const notifications = await notifFeed.get() 180 | 181 | const unread = notifications.results.filter( 182 | (notification) => !notification.is_seen 183 | ) 184 | 185 | setNewNotifications(unread.length) 186 | 187 | notifFeed.subscribe((data) => { 188 | setNewNotifications(newNotifications + data.new.length) 189 | }) 190 | } 191 | 192 | init() 193 | 194 | return () => notifFeed?.unsubscribe() 195 | }, [userData]) 196 | 197 | if (!userData) 198 | return ( 199 | 200 | 201 | 202 | ) 203 | 204 | const menus = [ 205 | { 206 | id: 'home', 207 | label: 'Home', 208 | Icon: Home, 209 | link: '/home', 210 | }, 211 | { 212 | id: 'explore', 213 | label: 'Explore', 214 | Icon: Hashtag, 215 | }, 216 | { 217 | id: 'communities', 218 | label: 'Communities', 219 | Icon: Group, 220 | }, 221 | { 222 | id: 'notifications', 223 | label: 'Notifications', 224 | Icon: Bell, 225 | link: '/notifications', 226 | value: newNotifications, 227 | onClick: () => setNewNotifications(0), 228 | }, 229 | { 230 | id: 'messages', 231 | label: 'Messages', 232 | Icon: Mail, 233 | }, 234 | { 235 | id: 'bookmarks', 236 | label: 'Bookmarks', 237 | Icon: Bookmark, 238 | }, 239 | { 240 | id: 'profile', 241 | label: 'Profile', 242 | Icon: User, 243 | link: `/${userData.id}`, 244 | }, 245 | ] 246 | 247 | return ( 248 | 249 | 250 | 251 | 252 |
253 | {menus.map((m) => { 254 | const isActiveLink = 255 | location.pathname === `/${m.id}` || 256 | (m.id === 'profile' && location.pathname === `/${userData.id}`) 257 | 258 | return ( 259 | 268 |
269 | {newNotifications && m.id === 'notifications' ? ( 270 | 271 | {newNotifications} 272 | 273 | ) : null} 274 | 275 |
276 | {m.label} 277 | 278 | ) 279 | })} 280 | 286 |
287 | 290 | 304 |
305 | ) 306 | } 307 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Container = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | justify-content: center; 8 | padding-top: 100px; 9 | background-color: black; 10 | 11 | .circle { 12 | border: 2px solid #333; 13 | border-radius: 50%; 14 | position: relative; 15 | width: 25px; 16 | height: 25px; 17 | 18 | &::after { 19 | content: ''; 20 | position: absolute; 21 | left: 0; 22 | top: 0; 23 | width: 100%; 24 | height: 100%; 25 | border-top: 2px solid var(--theme-color); 26 | border-radius: 50%; 27 | animation: spin 500ms infinite linear; 28 | 29 | @keyframes spin { 30 | from { 31 | transform: rotate(0deg); 32 | } 33 | to { 34 | transform: rotate(360deg); 35 | } 36 | } 37 | } 38 | } 39 | ` 40 | 41 | export default function LoadingIndicator() { 42 | return ( 43 | 44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import styled from 'styled-components' 3 | 4 | import Close from './Icons/Close' 5 | 6 | const Container = styled.div` 7 | position: fixed; 8 | z-index: 6; 9 | width: 100%; 10 | height: 100vh; 11 | display: flex; 12 | justify-content: center; 13 | padding: 30px 0; 14 | left: 0; 15 | top: 0; 16 | 17 | .modal { 18 | z-index: 2; 19 | position: relative; 20 | 21 | background-color: black; 22 | border-radius: 20px; 23 | 24 | .close-btn { 25 | position: relative; 26 | left: -10px; 27 | } 28 | } 29 | ` 30 | 31 | const Backdrop = styled.div` 32 | position: absolute; 33 | width: 100%; 34 | height: 100%; 35 | left: 0; 36 | top: 0; 37 | background-color: rgba(255, 255, 255, 0.2); 38 | ` 39 | 40 | export default function Modal({ className, children, onClickOutside }) { 41 | return ( 42 | 43 | onClickOutside()} /> 44 |
45 | 48 | {children} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Notification/CommentNotification.js: -------------------------------------------------------------------------------- 1 | import { useStreamContext } from 'react-activity-feed' 2 | import { Link, useNavigate } from 'react-router-dom' 3 | import styled from 'styled-components' 4 | 5 | import { generateTweetLink } from '../../utils/links' 6 | import TweetActorName from '../Tweet/TweetActorName' 7 | 8 | const Block = styled.button` 9 | padding: 15px; 10 | border-bottom: 1px solid #333; 11 | display: flex; 12 | 13 | a { 14 | color: white; 15 | } 16 | 17 | .user__image { 18 | width: 35px; 19 | height: 35px; 20 | overflow: hidden; 21 | border-radius: 50%; 22 | 23 | img { 24 | width: 100%; 25 | height: 100%; 26 | object-fit: cover; 27 | } 28 | } 29 | 30 | .user__details { 31 | margin-left: 20px; 32 | flex: 1; 33 | } 34 | 35 | .user__reply-to { 36 | color: #555; 37 | font-size: 15px; 38 | margin-top: 3px; 39 | 40 | a { 41 | color: var(--theme-color); 42 | &:hover { 43 | text-decoration: underline; 44 | } 45 | } 46 | } 47 | 48 | .user__text { 49 | display: block; 50 | color: white; 51 | margin-top: 10px; 52 | } 53 | ` 54 | 55 | export default function CommentNotification({ commentActivities }) { 56 | const navigate = useNavigate() 57 | const { user } = useStreamContext() 58 | 59 | return ( 60 | <> 61 | {commentActivities.map((cAct) => { 62 | const actor = cAct.actor 63 | 64 | const tweetLink = generateTweetLink(user.id, cAct.object.id) 65 | 66 | return ( 67 | navigate(tweetLink)}> 68 | 69 | 70 | 71 |
72 | 77 | 78 | Replying to @{user.id} 79 |

{cAct.text}

80 |
81 |
82 |
83 | ) 84 | })} 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/Notification/FollowNotification.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import styled from 'styled-components' 3 | 4 | import User from '../Icons/User' 5 | 6 | const Block = styled.div` 7 | padding: 15px; 8 | border-bottom: 1px solid #333; 9 | display: flex; 10 | 11 | a { 12 | color: white; 13 | } 14 | 15 | .right { 16 | margin-left: 20px; 17 | flex: 1; 18 | } 19 | 20 | .actors__images { 21 | display: flex; 22 | 23 | &__image { 24 | width: 35px; 25 | height: 35px; 26 | border-radius: 50%; 27 | overflow: hidden; 28 | margin-right: 10px; 29 | 30 | img { 31 | width: 100%; 32 | height: 100%; 33 | object-fit: cover; 34 | } 35 | } 36 | } 37 | 38 | .actors__text { 39 | margin-top: 10px; 40 | color: white; 41 | font-size: 15px; 42 | 43 | span { 44 | display: inline-block; 45 | } 46 | 47 | .actors__name { 48 | font-weight: bold; 49 | 50 | &:hover { 51 | text-decoration: underline; 52 | } 53 | } 54 | } 55 | ` 56 | 57 | export default function FollowNotification({ followActivities }) { 58 | const firstActivity = followActivities[0] 59 | 60 | return ( 61 | 62 | 63 |
64 |
65 | {followActivities.map((follow) => { 66 | return ( 67 | 72 | 73 | 74 | ) 75 | })} 76 |
77 |

78 | 79 | {firstActivity.actor.data.name} 80 | {' '} 81 | 82 | {followActivities.length > 1 && 83 | `and ${followActivities.length - 1} others`}{' '} 84 | followed you 85 | 86 |

87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/Notification/LikeNotification.js: -------------------------------------------------------------------------------- 1 | import { useStreamContext } from 'react-activity-feed' 2 | import { Link, useNavigate } from 'react-router-dom' 3 | import styled from 'styled-components' 4 | 5 | import Heart from '../Icons/Heart' 6 | 7 | const Block = styled.button` 8 | padding: 15px; 9 | border-bottom: 1px solid #333; 10 | display: flex; 11 | 12 | a { 13 | color: white; 14 | } 15 | 16 | span { 17 | display: inline-block; 18 | } 19 | 20 | .right { 21 | margin-left: 20px; 22 | flex: 1; 23 | } 24 | 25 | .liked-actors__images { 26 | display: flex; 27 | 28 | &__image { 29 | width: 35px; 30 | height: 35px; 31 | border-radius: 50%; 32 | overflow: hidden; 33 | margin-right: 10px; 34 | 35 | img { 36 | width: 100%; 37 | height: 100%; 38 | object-fit: cover; 39 | } 40 | } 41 | } 42 | 43 | .liked-actors__text { 44 | margin-top: 10px; 45 | color: white; 46 | font-size: 15px; 47 | 48 | .liked-actor__name { 49 | font-weight: bold; 50 | 51 | &:hover { 52 | text-decoration: underline; 53 | } 54 | } 55 | } 56 | 57 | .tweet-text { 58 | display: block; 59 | color: #888; 60 | margin-top: 10px; 61 | } 62 | ` 63 | 64 | export default function LikeNotification({ likedActivities }) { 65 | const likedGroup = {} 66 | const navigate = useNavigate() 67 | 68 | const { user } = useStreamContext() 69 | 70 | likedActivities.forEach((act) => { 71 | if (act.object.id in likedGroup) { 72 | likedGroup[act.object.id].push(act) 73 | } else likedGroup[act.object.id] = [act] 74 | }) 75 | 76 | return ( 77 | <> 78 | {Object.keys(likedGroup).map((groupKey) => { 79 | const activities = likedGroup[groupKey] 80 | 81 | const lastActivity = activities[0] 82 | 83 | const tweetLink = `/${user.id}/status/${lastActivity.object.id}` 84 | 85 | return ( 86 | navigate(tweetLink)} 89 | key={groupKey} 90 | > 91 | 92 |
93 |
94 | {activities.map((act) => ( 95 | 100 | 101 | 102 | ))} 103 |
104 | 105 | 109 | {lastActivity.actor.data.name} 110 | {' '} 111 | 112 | {activities.length > 1 && 113 | `and ${activities.length - 1} others`}{' '} 114 | liked your Tweet 115 | 116 | 117 | 118 |

{lastActivity.object.data.text}

119 |
120 |
121 | ) 122 | })} 123 | 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /src/components/Notification/NotificationContent.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useState } from 'react' 3 | import { NotificationFeed } from 'react-activity-feed' 4 | import styled from 'styled-components' 5 | 6 | import NotificationGroup from './NotificationGroup' 7 | import { useStreamContext } from 'react-activity-feed' 8 | import { useEffect } from 'react' 9 | 10 | const Container = styled.div` 11 | h1 { 12 | padding: 15px; 13 | font-size: 16px; 14 | color: white; 15 | } 16 | 17 | .tab-list { 18 | margin-top: 10px; 19 | border-bottom: 1px solid #333; 20 | display: grid; 21 | grid-template-columns: 1fr 1fr; 22 | 23 | .tab { 24 | color: #777; 25 | padding: 0 35px; 26 | width: 100%; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | font-weight: bold; 31 | font-size: 15px; 32 | 33 | &:hover { 34 | background-color: #111; 35 | } 36 | 37 | &__label { 38 | position: relative; 39 | padding: 20px 30px; 40 | 41 | &.active { 42 | color: white; 43 | 44 | &::after { 45 | content: ''; 46 | height: 3px; 47 | width: 100%; 48 | background-color: var(--theme-color); 49 | border-radius: 40px; 50 | position: absolute; 51 | bottom: 0; 52 | left: 0; 53 | } 54 | } 55 | } 56 | } 57 | } 58 | ` 59 | 60 | const tabList = [ 61 | { 62 | id: 'all', 63 | label: 'All', 64 | }, 65 | { 66 | id: 'mentions', 67 | label: 'Mentions', 68 | }, 69 | ] 70 | 71 | export default function NotificationContent() { 72 | const [activeTab, setActiveTab] = useState(tabList[0].id) 73 | 74 | const { client, user } = useStreamContext() 75 | 76 | useEffect(() => { 77 | async function init() { 78 | const notificationFeed = client.feed('notification', user.id) 79 | 80 | const activities = await notificationFeed.removeActivity("588498b4-ebe7-11ec-b097-0ebae3a0a17f") 81 | 82 | console.log({ activities }) 83 | } 84 | 85 | init() 86 | }, []) 87 | 88 | return ( 89 | 90 |

Notifications

91 |
92 | {tabList.map((tab) => ( 93 | 107 | ))} 108 |
109 | 110 |
111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/components/Notification/NotificationGroup.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { useFeedContext, useStreamContext } from 'react-activity-feed' 3 | import styled from 'styled-components' 4 | 5 | import CommentNotification from './CommentNotification' 6 | import FollowNotification from './FollowNotification' 7 | import LikeNotification from './LikeNotification' 8 | 9 | const Container = styled.div` 10 | button { 11 | width: 100%; 12 | } 13 | ` 14 | 15 | export default function NotificationGroup({ activityGroup }) { 16 | const feed = useFeedContext() 17 | const notificationContainerRef = useRef() 18 | 19 | const activities = activityGroup.activities 20 | 21 | const { user, client } = useStreamContext() 22 | 23 | useEffect(() => { 24 | // stop event propagation on links 25 | if (!notificationContainerRef.current) return 26 | 27 | const anchorTags = notificationContainerRef.current.querySelectorAll('a') 28 | 29 | anchorTags.forEach((element) => { 30 | element.addEventListener('click', (e) => e.stopPropagation()) 31 | }) 32 | 33 | return () => 34 | anchorTags.forEach((element) => { 35 | element.addEventListener('click', (e) => e.stopPropagation()) 36 | }) 37 | }, []) 38 | 39 | useEffect(() => { 40 | const notifFeed = client.feed('notification', user.id) 41 | 42 | notifFeed.subscribe((data) => { 43 | if (data.new.length) { 44 | feed.refresh() 45 | } 46 | }) 47 | 48 | return () => notifFeed.unsubscribe() 49 | }, []) 50 | 51 | return ( 52 | 53 | {activityGroup.verb === 'like' && ( 54 | 55 | )} 56 | {activityGroup.verb === 'follow' && ( 57 | 58 | )} 59 | {activityGroup.verb === 'comment' && ( 60 | 61 | )} 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileBio.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import styled from 'styled-components' 3 | import { format } from 'date-fns' 4 | import { useStreamContext } from 'react-activity-feed' 5 | 6 | import More from '../Icons/More' 7 | import Mail from '../Icons/Mail' 8 | import Calendar from '../Icons/Calendar' 9 | import { formatStringWithLink } from '../../utils/string' 10 | import { ProfileContext } from './ProfileContent' 11 | import FollowBtn from '../FollowBtn' 12 | 13 | const Container = styled.div` 14 | padding: 20px; 15 | position: relative; 16 | 17 | .top { 18 | display: flex; 19 | justify-content: space-between; 20 | margin-top: calc(var(--profile-image-size) / -2); 21 | 22 | .image { 23 | width: var(--profile-image-size); 24 | height: var(--profile-image-size); 25 | border-radius: 50%; 26 | overflow: hidden; 27 | border: 4px solid black; 28 | background-color: #444; 29 | 30 | img { 31 | width: 100%; 32 | height: 100%; 33 | object-fit: cover; 34 | } 35 | } 36 | 37 | .actions { 38 | position: relative; 39 | top: 55px; 40 | display: flex; 41 | 42 | .action-btn { 43 | border: 1px solid #777; 44 | margin-right: 10px; 45 | width: 30px; 46 | height: 30px; 47 | border-radius: 50%; 48 | display: flex; 49 | justify-content: center; 50 | align-items: center; 51 | } 52 | } 53 | } 54 | 55 | .details { 56 | color: #888; 57 | margin-top: 20px; 58 | 59 | .user { 60 | &__name { 61 | color: white; 62 | font-weight: bold; 63 | } 64 | 65 | &__id { 66 | margin-top: 2px; 67 | font-size: 15px; 68 | } 69 | 70 | &__bio { 71 | color: white; 72 | margin-top: 10px; 73 | a { 74 | color: var(--theme-color); 75 | text-decoration: none; 76 | } 77 | } 78 | 79 | &__joined { 80 | display: flex; 81 | align-items: center; 82 | margin-top: 15px; 83 | font-size: 15px; 84 | 85 | &--text { 86 | margin-left: 5px; 87 | } 88 | } 89 | 90 | &__follows { 91 | font-size: 15px; 92 | display: flex; 93 | margin-top: 15px; 94 | 95 | b { 96 | color: white; 97 | } 98 | 99 | &__followers { 100 | margin-left: 20px; 101 | } 102 | } 103 | 104 | &__followed-by { 105 | font-size: 13px; 106 | margin-top: 15px; 107 | } 108 | } 109 | } 110 | ` 111 | 112 | const actions = [ 113 | { 114 | Icon: More, 115 | id: 'more', 116 | }, 117 | { 118 | Icon: Mail, 119 | id: 'message', 120 | }, 121 | ] 122 | 123 | export default function ProfileBio() { 124 | const { client } = useStreamContext() 125 | const { user } = useContext(ProfileContext) 126 | 127 | const joinedDate = format(new Date(user.created_at), 'MMMM RRRR') 128 | 129 | const bio = formatStringWithLink(user.data.bio) 130 | 131 | const isLoggedInUserProfile = user.id === client.userId 132 | 133 | return ( 134 | 135 |
136 |
137 | {' '} 138 | 139 |
140 | {!isLoggedInUserProfile && ( 141 |
142 | {actions.map((action) => ( 143 | 146 | ))} 147 | 148 |
149 | )} 150 |
151 |
152 | {user.data.name} 153 | @{user.id} 154 | 155 |
156 | 157 | Joined {joinedDate} 158 |
159 |
160 | 161 | {user.following_count || 0} Following 162 | 163 | 164 | {user.followers_count || 0} Followers 165 | 166 |
167 |
168 | Not followed by anyone you are following 169 |
170 |
171 |
172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileContent.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { createContext, useEffect, useState } from 'react' 3 | import { useStreamContext } from 'react-activity-feed' 4 | import { useParams } from 'react-router-dom' 5 | 6 | import ProfileHeader from './ProfileHeader' 7 | import LoadingIndicator from '../LoadingIndicator' 8 | import ProfileBio from './ProfileBio' 9 | import TabList from './TabList' 10 | import ProfileTweets from './ProfileTweets' 11 | 12 | const Container = styled.div` 13 | --profile-image-size: 120px; 14 | 15 | .tab-list { 16 | margin-top: 30px; 17 | } 18 | ` 19 | 20 | export const ProfileContext = createContext() 21 | 22 | export default function ProfileContent() { 23 | const { client } = useStreamContext() 24 | 25 | const [user, setUser] = useState(null) 26 | const { user_id } = useParams() 27 | 28 | useEffect(() => { 29 | const getUser = async () => { 30 | const user = await client.user(user_id).get({ with_follow_counts: true }) 31 | 32 | setUser(user.full) 33 | } 34 | 35 | getUser() 36 | }, [user_id]) 37 | 38 | if (!client || !user) return 39 | 40 | return ( 41 | 42 | 43 | 44 |
45 | 46 |
47 | 48 |
49 | 50 |
51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileHeader.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react' 2 | import { useStreamContext } from 'react-activity-feed' 3 | import { useNavigate } from 'react-router-dom' 4 | import styled from 'styled-components' 5 | 6 | import ArrowLeft from '../Icons/ArrowLeft' 7 | import { ProfileContext } from './ProfileContent' 8 | 9 | const Header = styled.header` 10 | .top { 11 | display: flex; 12 | align-items: center; 13 | padding: 15px; 14 | color: white; 15 | width: 100%; 16 | backdrop-filter: blur(2px); 17 | background-color: rgba(0, 0, 0, 0.5); 18 | 19 | .info { 20 | margin-left: 30px; 21 | 22 | h1 { 23 | font-size: 20px; 24 | } 25 | 26 | &__tweets-count { 27 | font-size: 14px; 28 | margin-top: 2px; 29 | color: #888; 30 | } 31 | } 32 | } 33 | 34 | .cover { 35 | width: 100%; 36 | background-color: #555; 37 | height: 200px; 38 | overflow: hidden; 39 | 40 | img { 41 | width: 100%; 42 | object-fit: cover; 43 | object-position: center; 44 | } 45 | } 46 | ` 47 | 48 | export default function ProfileHeader() { 49 | const navigate = useNavigate() 50 | const { user } = useContext(ProfileContext) 51 | const { client } = useStreamContext() 52 | 53 | const [activitiesCount, setActivitiesCount] = useState(0) 54 | 55 | useEffect(() => { 56 | const feed = client.feed('user', user.id) 57 | 58 | async function getActivitiesCount() { 59 | const activities = await feed.get() 60 | 61 | setActivitiesCount(activities.results.length) 62 | } 63 | 64 | getActivitiesCount() 65 | }, []) 66 | 67 | const navigateBack = () => { 68 | navigate(-1) 69 | } 70 | 71 | return ( 72 |
73 |
74 | 77 |
78 |

{user.data.name}

79 | {activitiesCount} Tweets 80 |
81 |
82 |
83 | 84 |
85 |
86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileTweets.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { FlatFeed } from 'react-activity-feed' 3 | 4 | import TweetBlock from '../Tweet/TweetBlock' 5 | import { ProfileContext } from './ProfileContent' 6 | 7 | export default function MyTweets() { 8 | const { user } = useContext(ProfileContext) 9 | 10 | return ( 11 |
12 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Profile/TabList.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useState } from 'react' 3 | import styled from 'styled-components' 4 | 5 | const Container = styled.div` 6 | display: grid; 7 | grid-template-columns: 1fr 2fr 1fr 1fr; 8 | border-bottom: 1px solid #555; 9 | width: 100%; 10 | 11 | .tab { 12 | color: #777; 13 | padding: 0 35px; 14 | width: 100%; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | font-weight: bold; 19 | font-size: 15px; 20 | 21 | &:hover { 22 | background-color: #111; 23 | } 24 | 25 | &__label { 26 | position: relative; 27 | width: 100%; 28 | padding: 20px 7px; 29 | 30 | &.active { 31 | color: white; 32 | 33 | &::after { 34 | content: ''; 35 | height: 3px; 36 | width: 100%; 37 | background-color: var(--theme-color); 38 | border-radius: 40px; 39 | position: absolute; 40 | bottom: 0; 41 | left: 0; 42 | } 43 | } 44 | } 45 | } 46 | ` 47 | 48 | const tabs = [ 49 | { 50 | id: 'tweets', 51 | label: 'Tweets', 52 | }, 53 | { 54 | id: 'tweet-replies', 55 | label: 'Tweets & replies', 56 | }, 57 | { 58 | id: 'media', 59 | label: 'Media', 60 | }, 61 | { 62 | id: 'likes', 63 | label: 'Likes', 64 | }, 65 | ] 66 | 67 | export default function TabList() { 68 | const [activeTab, setActiveTab] = useState(tabs[0].id) 69 | 70 | return ( 71 | 72 | {tabs.map((tab) => ( 73 | 87 | ))} 88 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/RightSide.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useState } from 'react' 3 | import { useStreamContext } from 'react-activity-feed' 4 | import { Link } from 'react-router-dom' 5 | import styled from 'styled-components' 6 | 7 | import users from '../users' 8 | import FollowBtn from './FollowBtn' 9 | import More from './Icons/More' 10 | import Search from './Icons/Search' 11 | 12 | const Container = styled.div` 13 | padding: 0 15px 15px; 14 | 15 | .search-container { 16 | z-index: 1; 17 | position: sticky; 18 | background-color: black; 19 | width: var(--right); 20 | padding-right: 30px; 21 | top: 0; 22 | padding-top: 15px; 23 | padding-bottom: 10px; 24 | 25 | .search-form { 26 | width: 100%; 27 | position: relative; 28 | 29 | .search-icon { 30 | position: absolute; 31 | top: 0; 32 | bottom: 0; 33 | margin: auto 0; 34 | left: 15px; 35 | width: 18px; 36 | height: 18px; 37 | } 38 | 39 | input { 40 | width: 100%; 41 | background: none; 42 | border: none; 43 | background-color: #222; 44 | font-size: 15px; 45 | padding: 15px 50px; 46 | border-radius: 30px; 47 | color: white; 48 | 49 | &:focus { 50 | outline: none; 51 | border: 1px solid var(--theme-color); 52 | background-color: black; 53 | } 54 | } 55 | 56 | .submit-btn { 57 | &.hide { 58 | display: none; 59 | } 60 | 61 | position: absolute; 62 | right: 15px; 63 | top: 0; 64 | bottom: 0; 65 | margin: auto 0; 66 | background-color: var(--theme-color); 67 | color: black; 68 | border-radius: 50%; 69 | height: 25px; 70 | width: 25px; 71 | font-weight: bold; 72 | } 73 | } 74 | } 75 | 76 | .trends, 77 | .follows { 78 | background-color: #222; 79 | border-radius: 20px; 80 | padding: 15px; 81 | 82 | h2 { 83 | font-size: 20px; 84 | color: white; 85 | } 86 | } 87 | 88 | .trends { 89 | margin-top: 10px; 90 | &-list { 91 | margin-top: 30px; 92 | } 93 | 94 | .trend { 95 | display: flex; 96 | justify-content: space-between; 97 | margin-bottom: 30px; 98 | 99 | &__details { 100 | &__category { 101 | font-size: 13px; 102 | display: flex; 103 | color: #aaa; 104 | 105 | &--label { 106 | margin-left: 20px; 107 | position: relative; 108 | 109 | &::after { 110 | content: ''; 111 | width: 2px; 112 | height: 2px; 113 | background-color: #aaa; 114 | border-radius: 50%; 115 | left: -10px; 116 | top: 0; 117 | bottom: 0; 118 | margin: auto 0; 119 | position: absolute; 120 | } 121 | } 122 | } 123 | 124 | &__title { 125 | font-weight: bold; 126 | color: white; 127 | font-size: 16px; 128 | margin: 2px 0; 129 | display: block; 130 | } 131 | 132 | &__tweets-count { 133 | color: #aaa; 134 | font-size: 12px; 135 | } 136 | } 137 | 138 | .more-btn { 139 | opacity: 0.5; 140 | } 141 | } 142 | } 143 | 144 | .follows { 145 | margin-top: 20px; 146 | &-list { 147 | margin-top: 30px; 148 | } 149 | 150 | .user { 151 | margin-bottom: 30px; 152 | display: flex; 153 | justify-content: space-between; 154 | 155 | &__details { 156 | display: flex; 157 | text-decoration: none; 158 | } 159 | 160 | &__img { 161 | width: 40px; 162 | height: 40px; 163 | overflow: hidden; 164 | border-radius: 50%; 165 | margin-right: 10px; 166 | 167 | img { 168 | width: 100%; 169 | height: 100%; 170 | } 171 | } 172 | 173 | &__name { 174 | font-weight: bold; 175 | font-size: 16px; 176 | color: white; 177 | } 178 | 179 | &__id { 180 | color: #aaa; 181 | font-size: 14px; 182 | margin-top: 2px; 183 | } 184 | } 185 | 186 | .show-more-text { 187 | font-size: 14px; 188 | color: var(--theme-color); 189 | } 190 | } 191 | ` 192 | 193 | const trends = [ 194 | { 195 | title: 'iPhone 12', 196 | tweetsCount: '11.6k', 197 | category: 'Technology', 198 | }, 199 | { 200 | title: 'LinkedIn', 201 | tweetsCount: '51.1K', 202 | category: 'Business & finance', 203 | }, 204 | { 205 | title: 'John Cena', 206 | tweetsCount: '1,200', 207 | category: 'Sports', 208 | }, 209 | { 210 | title: '#Microsoft', 211 | tweetsCount: '3,022', 212 | category: 'Business & finance', 213 | }, 214 | { 215 | title: '#DataSciencve', 216 | tweetsCount: '18.6k', 217 | category: 'Technology', 218 | }, 219 | ] 220 | 221 | export default function RightSide() { 222 | const [searchText, setSearchText] = useState('') 223 | 224 | const { client } = useStreamContext() 225 | 226 | const whoToFollow = users.filter((u) => { 227 | // filter out currently logged in user 228 | return u.id !== client.userId 229 | }) 230 | 231 | return ( 232 | 233 |
234 |
235 |
236 | 237 |
238 | setSearchText(e.target.value)} 240 | value={searchText} 241 | placeholder="Search Streamer" 242 | /> 243 | 250 |
251 |
252 | 253 |
254 |

Trends for you

255 |
256 | {trends.map((trend, i) => { 257 | return ( 258 |
259 |
260 |
261 | {trend.category} 262 | 263 | Trending 264 | 265 |
266 | {trend.title} 267 | 268 | {trend.tweetsCount} Tweets 269 | 270 |
271 | 274 |
275 | ) 276 | })} 277 |
278 |
279 | 280 |
281 |

Who to follow

282 |
283 | {whoToFollow.map((user) => { 284 | return ( 285 |
286 | 287 |
288 | 289 |
290 |
291 | {user.name} 292 | @{user.id} 293 |
294 | 295 | 296 |
297 | ) 298 | })} 299 |
300 | Show more 301 |
302 |
303 | ) 304 | } 305 | -------------------------------------------------------------------------------- /src/components/ScrollToTop.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useLocation } from 'react-router' 3 | 4 | const ScrollToTop = (props) => { 5 | const location = useLocation() 6 | 7 | useEffect(() => { 8 | window.scrollTo(0, 0) 9 | }, [location]) 10 | 11 | return <>{props.children} 12 | } 13 | 14 | export default ScrollToTop 15 | -------------------------------------------------------------------------------- /src/components/Thread/ThreadContent.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useFeedContext, useStreamContext } from 'react-activity-feed' 3 | import { useParams } from 'react-router-dom' 4 | 5 | import LoadingIndicator from '../LoadingIndicator' 6 | import TweetContent from './TweetContent' 7 | import ThreadHeader from './ThreadHeader' 8 | 9 | export default function ThreadContent() { 10 | const { client } = useStreamContext() 11 | const { id } = useParams() 12 | 13 | const feed = useFeedContext() 14 | 15 | const [activity, setActivity] = useState(null) 16 | 17 | useEffect(() => { 18 | if (feed.refreshing || !feed.hasDoneRequest) return 19 | 20 | const activityPaths = feed.feedManager.getActivityPaths(id) || [] 21 | 22 | if (activityPaths.length) { 23 | const targetActivity = feed.feedManager.state.activities 24 | .getIn([...activityPaths[0]]) 25 | .toJS() 26 | 27 | setActivity(targetActivity) 28 | } 29 | }, [feed.refreshing]) 30 | 31 | if (!client || !activity) return 32 | 33 | return ( 34 |
35 | 36 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Thread/ThreadHeader.js: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom' 2 | import styled from 'styled-components' 3 | 4 | import ArrowLeft from '../Icons/ArrowLeft' 5 | 6 | const Header = styled.header` 7 | display: flex; 8 | align-items: center; 9 | padding: 15px; 10 | 11 | button { 12 | width: 25px; 13 | height: 20px; 14 | margin-right: 40px; 15 | } 16 | 17 | span { 18 | font-size: 20px; 19 | color: white; 20 | font-weight: bold; 21 | } 22 | ` 23 | 24 | export default function ThreadHeader() { 25 | const navigate = useNavigate() 26 | 27 | const navigateBack = () => { 28 | navigate(-1) 29 | } 30 | 31 | return ( 32 |
33 | 36 | Tweet 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Thread/TweetCommentBlock.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { formatStringWithLink } from '../../utils/string' 4 | import More from '../Icons/More' 5 | import TweetActorName from '../Tweet/TweetActorName' 6 | 7 | const Block = styled.div` 8 | display: flex; 9 | border-bottom: 1px solid #333; 10 | padding: 15px 0; 11 | 12 | .user-image { 13 | width: 40px; 14 | height: 40px; 15 | border-radius: 50%; 16 | overflow: hidden; 17 | margin-right: 15px; 18 | 19 | img { 20 | width: 100%; 21 | height: 100%; 22 | object-fit: cover; 23 | } 24 | } 25 | 26 | .comment-tweet { 27 | flex: 1; 28 | .link { 29 | display: block; 30 | padding-bottom: 5px; 31 | text-decoration: none; 32 | } 33 | 34 | &__text { 35 | color: white; 36 | font-size: 15px; 37 | line-height: 20px; 38 | margin-top: 3px; 39 | 40 | &--link { 41 | color: var(--theme-color); 42 | text-decoration: none; 43 | } 44 | } 45 | } 46 | 47 | .more { 48 | width: 30px; 49 | height: 20px; 50 | display: flex; 51 | opacity: 0.6; 52 | } 53 | ` 54 | 55 | export default function TweetCommentBlock({ comment }) { 56 | const { user, data: tweetComment } = comment 57 | 58 | return ( 59 | 60 |
61 | 62 |
63 |
64 |
65 | 70 |
71 |

'), 78 | }} 79 | /> 80 |

81 |
82 |
83 | 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/components/Thread/TweetContent.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import { useFeedContext, useStreamContext } from 'react-activity-feed' 3 | import { Link } from 'react-router-dom' 4 | import styled from 'styled-components' 5 | import { useState } from 'react' 6 | 7 | import { formatStringWithLink } from '../../utils/string' 8 | import BarChart from '../Icons/BarChart' 9 | import Comment from '../Icons/Comment' 10 | import Retweet from '../Icons/Retweet' 11 | import Heart from '../Icons/Heart' 12 | import Upload from '../Icons/Upload' 13 | import TweetForm from '../Tweet/TweetForm' 14 | import TweetCommentBlock from './TweetCommentBlock' 15 | import CommentDialog from '../Tweet/CommentDialog' 16 | import More from '../Icons/More' 17 | import useComment from '../../hooks/useComment' 18 | import useLike from '../../hooks/useLike' 19 | 20 | const Container = styled.div` 21 | padding: 10px 15px; 22 | 23 | .user { 24 | display: flex; 25 | text-decoration: none; 26 | 27 | &__image { 28 | width: 40px; 29 | height: 40px; 30 | border-radius: 50%; 31 | overflow: hidden; 32 | margin-right: 15px; 33 | 34 | img { 35 | width: 100%; 36 | height: 100%; 37 | } 38 | } 39 | 40 | &__name { 41 | &--name { 42 | color: white; 43 | font-weight: bold; 44 | } 45 | &--id { 46 | color: #52575b; 47 | font-size: 14px; 48 | } 49 | } 50 | 51 | &__option { 52 | margin-left: auto; 53 | } 54 | } 55 | 56 | .tweet { 57 | margin-top: 20px; 58 | 59 | a { 60 | text-decoration: none; 61 | color: var(--theme-color); 62 | } 63 | 64 | &__text { 65 | color: white; 66 | font-size: 20px; 67 | } 68 | 69 | &__time, 70 | &__analytics, 71 | &__reactions, 72 | &__reactors { 73 | height: 50px; 74 | display: flex; 75 | align-items: center; 76 | border-bottom: 1px solid #555; 77 | font-size: 15px; 78 | color: #888; 79 | } 80 | 81 | &__time { 82 | &--date { 83 | margin-left: 12px; 84 | position: relative; 85 | 86 | &::after { 87 | position: absolute; 88 | content: ''; 89 | width: 2px; 90 | height: 2px; 91 | background-color: #777; 92 | border-radius: 50%; 93 | top: 0; 94 | bottom: 0; 95 | left: -7px; 96 | margin: auto 0; 97 | } 98 | } 99 | } 100 | 101 | &__analytics { 102 | &__text { 103 | margin-left: 7px; 104 | } 105 | } 106 | 107 | &__reactions { 108 | &__likes { 109 | display: flex; 110 | 111 | .reaction-count { 112 | color: white; 113 | font-weight: bold; 114 | } 115 | 116 | .reaction-label { 117 | margin-left: 4px; 118 | } 119 | } 120 | } 121 | 122 | &__reactors { 123 | justify-content: space-between; 124 | padding: 0 50px; 125 | } 126 | } 127 | 128 | .write-reply { 129 | align-items: center; 130 | padding: 15px 0; 131 | border-bottom: 1px solid #555; 132 | } 133 | ` 134 | 135 | export default function TweetContent({ activity }) { 136 | const feed = useFeedContext() 137 | const { client } = useStreamContext() 138 | 139 | const { createComment } = useComment() 140 | const { toggleLike } = useLike() 141 | 142 | const time = format(new Date(activity.time), 'p') 143 | const date = format(new Date(activity.time), 'PP') 144 | 145 | const tweet = activity.object.data 146 | const tweetActor = activity.actor.data 147 | 148 | const [commentDialogOpened, setCommentDialogOpened] = useState(false) 149 | 150 | let hasLikedTweet = false 151 | 152 | if (activity?.own_reactions?.like) { 153 | const myReaction = activity.own_reactions.like.find( 154 | (l) => l.user.id === client.userId 155 | ) 156 | hasLikedTweet = Boolean(myReaction) 157 | } 158 | 159 | const onToggleLike = async () => { 160 | await toggleLike(activity, hasLikedTweet) 161 | feed.refresh() 162 | } 163 | 164 | const reactors = [ 165 | { 166 | id: 'comment', 167 | Icon: Comment, 168 | onClick: () => setCommentDialogOpened(true), 169 | }, 170 | { id: 'retweet', Icon: Retweet }, 171 | { 172 | id: 'heart', 173 | Icon: Heart, 174 | onClick: onToggleLike, 175 | }, 176 | { id: 'upload', Icon: Upload }, 177 | ] 178 | 179 | const onPostComment = async (text) => { 180 | await createComment(text, activity) 181 | 182 | feed.refresh() 183 | } 184 | 185 | return ( 186 | <> 187 | {commentDialogOpened && ( 188 | setCommentDialogOpened(false)} 192 | /> 193 | )} 194 | 195 | 196 |
197 | 198 |
199 |
200 | {tweetActor.name} 201 | @{tweetActor.id} 202 |
203 |
204 | 205 |
206 | 207 |
208 |

'), 215 | }} 216 | /> 217 |

218 | {time} 219 | {date} 220 |
221 | 222 |
223 | 224 | View Tweet Analytics 225 |
226 | 227 |
228 |
229 | 230 | {activity.reaction_counts.like || '0'} 231 | 232 | Likes 233 |
234 |
235 | 236 |
237 | {reactors.map((action, i) => ( 238 | 249 | ))} 250 |
251 |
252 | 253 |
254 | 261 |
262 | {activity.latest_reactions?.comment?.map((comment) => ( 263 | 264 | ))} 265 |
266 | 267 | ) 268 | } 269 | -------------------------------------------------------------------------------- /src/components/Tweet/CommentDialog.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { formatStringWithLink } from '../../utils/string' 4 | import Modal from '../Modal' 5 | import TweetActorName from './TweetActorName' 6 | import TweetForm from './TweetForm' 7 | 8 | const Container = styled.div` 9 | .modal-block { 10 | padding: 15px; 11 | width: 600px; 12 | height: max-content; 13 | } 14 | ` 15 | 16 | const BlockContent = styled.div` 17 | .tweet { 18 | margin-top: 30px; 19 | display: flex; 20 | position: relative; 21 | 22 | &::after { 23 | content: ''; 24 | background-color: #444; 25 | width: 2px; 26 | height: calc(100% - 35px); 27 | position: absolute; 28 | left: 20px; 29 | z-index: 0; 30 | top: 45px; 31 | } 32 | 33 | .img { 34 | width: 40px; 35 | height: 40px; 36 | border-radius: 50%; 37 | margin-right: 15px; 38 | border-radius: 50%; 39 | overflow: hidden; 40 | 41 | img { 42 | width: 100%; 43 | height: 100%; 44 | object-fit: cover; 45 | } 46 | } 47 | 48 | .details { 49 | .actor-name { 50 | font-size: 15px; 51 | &--name { 52 | color: white; 53 | font-weight: bold; 54 | } 55 | 56 | &--id { 57 | color: #888; 58 | } 59 | } 60 | 61 | .tweet-text { 62 | color: white; 63 | margin-top: 3px; 64 | font-size: 14px; 65 | } 66 | 67 | .replying-info { 68 | color: #555; 69 | display: flex; 70 | margin-top: 20px; 71 | font-size: 14px; 72 | 73 | &--actor { 74 | margin-left: 5px; 75 | color: var(--theme-color); 76 | } 77 | } 78 | } 79 | } 80 | 81 | .comment { 82 | display: flex; 83 | margin-top: 20px; 84 | 85 | .img { 86 | width: 35px; 87 | height: 35px; 88 | margin-left: 3px; 89 | border-radius: 50%; 90 | margin-right: 15px; 91 | border-radius: 50%; 92 | overflow: hidden; 93 | 94 | img { 95 | width: 100%; 96 | height: 100%; 97 | object-fit: cover; 98 | } 99 | } 100 | 101 | .comment-form { 102 | flex: 1; 103 | height: 120px; 104 | } 105 | } 106 | ` 107 | 108 | export default function CommentDialog({ 109 | activity, 110 | onPostComment, 111 | onClickOutside, 112 | }) { 113 | const { 114 | object: { data: tweet }, 115 | } = activity 116 | 117 | const tweetActor = activity.actor 118 | 119 | const onSubmit = async (text) => { 120 | await onPostComment(text) 121 | 122 | onClickOutside() 123 | } 124 | 125 | return ( 126 | 127 | 128 | 129 |
130 |
131 | 132 |
133 |
134 | 139 |

'), 147 | }} 148 | /> 149 |

150 | Replying to{' '} 151 | @{tweetActor.id} 152 |
153 |
154 |
155 |
156 | 163 |
164 |
165 |
166 |
167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /src/components/Tweet/CreateTweetDialog.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import Modal from '../Modal' 4 | import useTweet from '../../hooks/useTweet' 5 | import TweetForm from './TweetForm' 6 | 7 | const Container = styled.div` 8 | .modal-block { 9 | margin-top: 20px; 10 | padding: 15px; 11 | width: 600px; 12 | height: max-content; 13 | z-index: 10; 14 | } 15 | 16 | .tweet-form { 17 | margin-top: 20px; 18 | } 19 | ` 20 | 21 | export default function CreateTweetDialog({ onClickOutside }) { 22 | const { createTweet } = useTweet() 23 | 24 | const onSubmit = async (text) => { 25 | createTweet(text) 26 | 27 | onClickOutside() 28 | } 29 | 30 | return ( 31 | 32 | 33 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Tweet/TweetActorName.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import { Link } from 'react-router-dom' 3 | import styled from 'styled-components' 4 | 5 | const TextBlock = styled(Link)` 6 | display: flex; 7 | 8 | &:hover .user--name { 9 | text-decoration: underline; 10 | } 11 | 12 | .user { 13 | &--name { 14 | color: white; 15 | font-weight: bold; 16 | } 17 | &--id { 18 | margin-left: 5px; 19 | color: #777; 20 | } 21 | } 22 | .tweet-date { 23 | margin-left: 15px; 24 | color: #777; 25 | position: relative; 26 | 27 | &::after { 28 | content: ''; 29 | width: 2px; 30 | height: 2px; 31 | background-color: #777; 32 | position: absolute; 33 | left: -8px; 34 | top: 0; 35 | bottom: 0; 36 | margin: auto 0; 37 | } 38 | } 39 | ` 40 | 41 | export default function TweetActorName({ time, name, id }) { 42 | const timeDiff = Date.now() - new Date(time).getTime() 43 | 44 | // convert ms to hours 45 | const hoursBetweenDates = timeDiff / (60 * 60 * 1000) 46 | 47 | const lessThan24hrs = hoursBetweenDates < 24 48 | 49 | const lessThan1hr = hoursBetweenDates < 1 50 | 51 | const timeText = lessThan1hr 52 | ? format(timeDiff, 'm') + 'm' 53 | : lessThan24hrs 54 | ? format(timeDiff, 'H') + 'h' 55 | : format(new Date(time), 'MMM d') 56 | 57 | return ( 58 | 59 | {name} 60 | @{id} 61 | {timeText} 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Tweet/TweetBlock.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useState } from 'react' 3 | import { useStreamContext } from 'react-activity-feed' 4 | import { useNavigate } from 'react-router-dom' 5 | import styled from 'styled-components' 6 | 7 | import { formatStringWithLink } from '../../utils/string' 8 | import CommentDialog from './CommentDialog' 9 | import Comment from '../Icons/Comment' 10 | import Heart from '../Icons/Heart' 11 | import Retweet from '../Icons/Retweet' 12 | import Upload from '../Icons/Upload' 13 | import More from '../Icons/More' 14 | import TweetActorName from './TweetActorName' 15 | import { generateTweetLink } from '../../utils/links' 16 | import useComment from '../../hooks/useComment' 17 | import useLike from '../../hooks/useLike' 18 | 19 | const Block = styled.div` 20 | display: flex; 21 | border-bottom: 1px solid #333; 22 | padding: 15px; 23 | 24 | .user-image { 25 | width: 40px; 26 | height: 40px; 27 | border-radius: 50%; 28 | overflow: hidden; 29 | margin-right: 10px; 30 | 31 | img { 32 | width: 100%; 33 | height: 100%; 34 | object-fit: cover; 35 | } 36 | } 37 | 38 | .tweet { 39 | flex: 1; 40 | .link { 41 | display: block; 42 | padding-bottom: 5px; 43 | text-decoration: none; 44 | width: 100%; 45 | } 46 | 47 | &__text { 48 | color: white; 49 | font-size: 15px; 50 | line-height: 20px; 51 | margin-top: 3px; 52 | width: 100%; 53 | 54 | &--link { 55 | color: var(--theme-color); 56 | text-decoration: none; 57 | } 58 | } 59 | 60 | &__actions { 61 | display: flex; 62 | justify-content: space-between; 63 | margin-top: 5px; 64 | 65 | button { 66 | display: flex; 67 | align-items: center; 68 | } 69 | 70 | &__value { 71 | margin-left: 10px; 72 | color: #666; 73 | 74 | &.colored { 75 | color: var(--theme-color); 76 | } 77 | } 78 | } 79 | 80 | &__image { 81 | margin-top: 20px; 82 | border-radius: 20px; 83 | border: 1px solid #333; 84 | overflow: hidden; 85 | width: calc(100% + 20px); 86 | 87 | width: 100%; 88 | height: 100%; 89 | object-fit: cover; 90 | object-position: center; 91 | } 92 | } 93 | 94 | .more { 95 | width: 40px; 96 | height: 40px; 97 | display: flex; 98 | } 99 | ` 100 | 101 | export default function TweetBlock({ activity }) { 102 | const { user } = useStreamContext() 103 | const navigate = useNavigate() 104 | const [commentDialogOpened, setCommentDialogOpened] = useState(false) 105 | 106 | const { createComment } = useComment() 107 | 108 | const actor = activity.actor 109 | 110 | let hasLikedTweet = false 111 | 112 | const tweet = activity.object.data 113 | 114 | // check if current logged in user has liked tweet 115 | if (activity?.own_reactions?.like) { 116 | const myReaction = activity.own_reactions.like.find( 117 | (l) => l.user.id === user.id 118 | ) 119 | hasLikedTweet = Boolean(myReaction) 120 | } 121 | 122 | const { toggleLike } = useLike() 123 | 124 | const onToggleLike = async () => { 125 | await toggleLike(activity, hasLikedTweet) 126 | } 127 | 128 | const actions = [ 129 | { 130 | id: 'comment', 131 | Icon: Comment, 132 | alt: 'Comment', 133 | value: activity?.reaction_counts?.comment || 0, 134 | onClick: () => setCommentDialogOpened(true), 135 | }, 136 | { 137 | id: 'retweet', 138 | Icon: Retweet, 139 | alt: 'Retweet', 140 | value: 0, 141 | }, 142 | { 143 | id: 'heart', 144 | Icon: Heart, 145 | alt: 'Heart', 146 | value: activity?.reaction_counts?.like || 0, 147 | onClick: onToggleLike, 148 | }, 149 | { 150 | id: 'upload', 151 | Icon: Upload, 152 | alt: 'Upload', 153 | }, 154 | ] 155 | 156 | const tweetLink = activity.id ? generateTweetLink(actor.id, activity.id) : '#' 157 | 158 | const onPostComment = async (text) => { 159 | await createComment(text, activity) 160 | } 161 | 162 | return ( 163 | <> 164 | 165 |
166 | 167 |
168 |
169 | 187 | 188 |
189 | {actions.map((action) => { 190 | return ( 191 | 216 | ) 217 | })} 218 |
219 |
220 | 223 |
224 | {activity.id && commentDialogOpened && ( 225 | setCommentDialogOpened(false)} 229 | activity={activity} 230 | /> 231 | )} 232 | 233 | ) 234 | } 235 | -------------------------------------------------------------------------------- /src/components/Tweet/TweetForm.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { useStreamContext } from 'react-activity-feed' 4 | import styled from 'styled-components' 5 | 6 | import Calendar from '../Icons/Calendar' 7 | import Emoji from '../Icons/Emoji' 8 | import Gif from '../Icons/Gif' 9 | import Image from '../Icons/Image' 10 | import Location from '../Icons/Location' 11 | import Poll from '../Icons/Poll' 12 | import ProgressRing from '../Icons/ProgressRing' 13 | 14 | const Container = styled.div` 15 | width: 100%; 16 | 17 | .reply-to { 18 | font-size: 14px; 19 | color: #888; 20 | display: flex; 21 | margin-left: 55px; 22 | margin-bottom: 10px; 23 | 24 | &--name { 25 | margin-left: 4px; 26 | color: var(--theme-color); 27 | } 28 | } 29 | ` 30 | 31 | const Form = styled.form` 32 | width: 100%; 33 | display: flex; 34 | align-items: ${({ inline }) => (inline ? 'center' : 'initial')}; 35 | 36 | .user { 37 | width: 40px; 38 | height: 40px; 39 | border-radius: 50%; 40 | overflow: hidden; 41 | margin-right: 15px; 42 | 43 | img { 44 | width: 100%; 45 | height: 100%; 46 | object-fit: cover; 47 | } 48 | } 49 | 50 | .input-section { 51 | width: 100%; 52 | display: flex; 53 | flex: 1; 54 | flex-direction: ${({ inline }) => (inline ? 'row' : 'column')}; 55 | align-items: ${({ inline }) => (inline ? 'center' : 'initial')}; 56 | height: ${({ inline, minHeight }) => (inline ? '40px' : minHeight)}; 57 | 58 | textarea { 59 | padding-top: 10px; 60 | background: none; 61 | border: none; 62 | padding-bottom: 0; 63 | font-size: 18px; 64 | width: 100%; 65 | flex: 1; 66 | resize: none; 67 | outline: none; 68 | color: white; 69 | } 70 | 71 | .actions { 72 | margin-top: ${({ inline }) => (inline ? '0' : 'auto')}; 73 | display: flex; 74 | height: 50px; 75 | align-items: center; 76 | 77 | button { 78 | &:disabled { 79 | opacity: 0.5; 80 | } 81 | } 82 | 83 | .right { 84 | margin-left: auto; 85 | display: flex; 86 | align-items: center; 87 | } 88 | 89 | .tweet-length { 90 | position: relative; 91 | 92 | svg { 93 | position: relative; 94 | top: 2px; 95 | } 96 | 97 | &__text { 98 | position: absolute; 99 | color: #888; 100 | font-size: 14px; 101 | top: 0; 102 | bottom: 0; 103 | left: 0; 104 | right: 0; 105 | margin: auto; 106 | height: max-content; 107 | width: max-content; 108 | 109 | &.red { 110 | color: red; 111 | } 112 | } 113 | } 114 | 115 | .divider { 116 | height: 30px; 117 | width: 2px; 118 | border: none; 119 | background-color: #444; 120 | margin: 0 18px; 121 | } 122 | 123 | .submit-btn { 124 | background-color: var(--theme-color); 125 | padding: 10px 20px; 126 | color: white; 127 | border-radius: 30px; 128 | margin-left: auto; 129 | font-weight: bold; 130 | font-size: 16px; 131 | 132 | &:disabled { 133 | opacity: 0.6; 134 | } 135 | } 136 | } 137 | } 138 | ` 139 | 140 | const actions = [ 141 | { 142 | id: 'image', 143 | Icon: Image, 144 | alt: 'Image', 145 | }, 146 | { 147 | id: 'gif', 148 | Icon: Gif, 149 | alt: 'GIF', 150 | }, 151 | { 152 | id: 'poll', 153 | Icon: Poll, 154 | alt: 'Poll', 155 | }, 156 | { 157 | id: 'emoji', 158 | Icon: Emoji, 159 | alt: 'Emoji', 160 | }, 161 | { 162 | id: 'schedule', 163 | Icon: Calendar, 164 | alt: 'Schedule', 165 | }, 166 | { 167 | id: 'location', 168 | Icon: Location, 169 | alt: 'Location', 170 | }, 171 | ] 172 | 173 | export default function TweetForm({ 174 | submitText = 'Tweet', 175 | onSubmit, 176 | className, 177 | placeholder, 178 | collapsedOnMount = false, 179 | minHeight = 120, 180 | shouldFocus = false, 181 | replyingTo = null, 182 | }) { 183 | const inputRef = useRef(null) 184 | 185 | const { client } = useStreamContext() 186 | 187 | const [expanded, setExpanded] = useState(!collapsedOnMount) 188 | const [text, setText] = useState('') 189 | 190 | useEffect(() => { 191 | if (shouldFocus && inputRef.current) inputRef.current.focus() 192 | }, []) 193 | 194 | const user = client.currentUser.data 195 | 196 | const MAX_CHARS = 280 197 | 198 | const percentage = 199 | text.length >= MAX_CHARS ? 100 : (text.length / MAX_CHARS) * 100 200 | 201 | const submit = async (e) => { 202 | e.preventDefault() 203 | 204 | if (exceededMax) 205 | return alert('Tweet cannot exceed ' + MAX_CHARS + ' characters') 206 | 207 | await onSubmit(text) 208 | 209 | setText('') 210 | } 211 | 212 | const onClick = () => { 213 | setExpanded(true) 214 | } 215 | 216 | const isInputEmpty = !Boolean(text) 217 | 218 | const charsLeft = MAX_CHARS - text.length 219 | const maxAlmostReached = charsLeft <= 20 220 | const exceededMax = charsLeft < 0 221 | 222 | const isReplying = Boolean(replyingTo) 223 | 224 | return ( 225 | 226 | {isReplying && expanded && ( 227 | 228 | Replying to @{replyingTo} 229 | 230 | )} 231 |
237 |
238 | 239 |
240 |
241 |