├── .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 | 
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 |
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 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/BarChart.js:
--------------------------------------------------------------------------------
1 | export default function BarChart({ color = 'block', size = 18 }) {
2 | return (
3 |
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 |
14 | )
15 |
16 | return (
17 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Icons/Bookmark.js:
--------------------------------------------------------------------------------
1 | export default function Bookmark({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Calendar.js:
--------------------------------------------------------------------------------
1 | export default function Calendar({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Close.js:
--------------------------------------------------------------------------------
1 | export default function Close({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Comment.js:
--------------------------------------------------------------------------------
1 | export default function Comment({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Emoji.js:
--------------------------------------------------------------------------------
1 | export default function Emoji({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Gif.js:
--------------------------------------------------------------------------------
1 | export default function Gif({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Group.js:
--------------------------------------------------------------------------------
1 | export default function Group({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Hashtag.js:
--------------------------------------------------------------------------------
1 | export default function Hashtag({ color = 'block', size = 18 }) {
2 | return (
3 |
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 |
14 | )
15 |
16 | return (
17 |
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 |
14 | )
15 |
16 | return (
17 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Icons/Image.js:
--------------------------------------------------------------------------------
1 | export default function Image({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Location.js:
--------------------------------------------------------------------------------
1 | export default function Location({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Mail.js:
--------------------------------------------------------------------------------
1 | export default function Mail({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/More.js:
--------------------------------------------------------------------------------
1 | export default function More({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Poll.js:
--------------------------------------------------------------------------------
1 | export default function Poll({ color = 'block', size = 18 }) {
2 | return (
3 |
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 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/Icons/Retweet.js:
--------------------------------------------------------------------------------
1 | export default function Retweet({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Search.js:
--------------------------------------------------------------------------------
1 | export default function Search({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Star.js:
--------------------------------------------------------------------------------
1 | export default function Star({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Twitter.js:
--------------------------------------------------------------------------------
1 | export default function Twitter({ color = 'block', size = 18 }) {
2 | return (
3 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Icons/Upload.js:
--------------------------------------------------------------------------------
1 | export default function Upload({ color = 'block', size = 18 }) {
2 | return (
3 |
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 |
14 | )
15 |
16 | return (
17 |
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 |
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 |
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 |
300 |
301 | )
302 | }
303 |
--------------------------------------------------------------------------------
/src/hooks/useComment.js:
--------------------------------------------------------------------------------
1 | import { useFeedContext, useStreamContext } from 'react-activity-feed'
2 |
3 | import useNotification from './useNotification'
4 |
5 | export default function useComment() {
6 | const feed = useFeedContext()
7 | const { createNotification } = useNotification()
8 | const { user } = useStreamContext()
9 |
10 | const createComment = async (text, activity) => {
11 | const actor = activity.actor
12 |
13 | await feed.onAddReaction('comment', activity, {
14 | text,
15 | })
16 |
17 | if (actor.id !== user.id) {
18 | // then it is not the logged in user commenting on their own tweet
19 |
20 | createNotification(
21 | actor.id,
22 | 'comment',
23 | {
24 | text,
25 | },
26 | `SO:tweet:${activity.object.id}`
27 | )
28 | }
29 | }
30 |
31 | return {
32 | createComment,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/hooks/useFollow.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useStreamContext } from 'react-activity-feed'
3 |
4 | import useNotification from './useNotification'
5 |
6 | export default function useFollow({ userId }) {
7 | const { client } = useStreamContext()
8 | const { createNotification } = useNotification()
9 | const [isFollowing, setIsFollowing] = useState(false)
10 |
11 | useEffect(() => {
12 | async function init() {
13 | const response = await client
14 | .feed('timeline', client.userId)
15 | .following({ filter: [`user:${userId}`] })
16 |
17 | setIsFollowing(!!response.results.length)
18 | }
19 |
20 | init()
21 | }, [])
22 |
23 | const toggleFollow = async () => {
24 | const action = isFollowing ? 'unfollow' : 'follow'
25 |
26 | if (action === 'follow') {
27 | await createNotification(userId, 'follow')
28 | }
29 |
30 | const timelineFeed = client.feed('timeline', client.userId)
31 | await timelineFeed[action]('user', userId)
32 |
33 | setIsFollowing((isFollowing) => !isFollowing)
34 | }
35 |
36 | return { isFollowing, toggleFollow }
37 | }
38 |
--------------------------------------------------------------------------------
/src/hooks/useLike.js:
--------------------------------------------------------------------------------
1 | import { useFeedContext, useStreamContext } from 'react-activity-feed'
2 |
3 | import useNotification from './useNotification'
4 |
5 | export default function useLike() {
6 | const feed = useFeedContext()
7 | const { createNotification } = useNotification()
8 | const { user } = useStreamContext()
9 |
10 | const toggleLike = async (activity, hasLikedTweet) => {
11 | const actor = activity.actor
12 |
13 | await feed.onToggleReaction('like', activity)
14 |
15 | if (!hasLikedTweet && actor.id !== user.id) {
16 | // then it is not the logged in user liking their own tweet
17 | createNotification(actor.id, 'like', {}, `SO:tweet:${activity.object.id}`)
18 | }
19 | }
20 |
21 | return { toggleLike }
22 | }
23 |
--------------------------------------------------------------------------------
/src/hooks/useNotification.js:
--------------------------------------------------------------------------------
1 | import { useStreamContext } from 'react-activity-feed'
2 |
3 | export default function useNotification() {
4 | const { client } = useStreamContext()
5 |
6 | const createNotification = async (userId, verb, data, reference = {}) => {
7 | const userNotificationFeed = client.feed('notification', userId)
8 |
9 | const newActivity = {
10 | verb,
11 | object: reference,
12 | ...data,
13 | }
14 |
15 | await userNotificationFeed.addActivity(newActivity)
16 | }
17 |
18 | return { createNotification }
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/useTweet.js:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import { useStreamContext } from 'react-activity-feed'
3 |
4 | export default function useTweet() {
5 | const { client } = useStreamContext()
6 |
7 | const user = client.feed('user', client.userId)
8 |
9 | const createTweet = async (text) => {
10 | const collection = await client.collections.add('tweet', nanoid(), { text })
11 |
12 | await user.addActivity({
13 | verb: 'tweet',
14 | object: `SO:tweet:${collection.id}`,
15 | })
16 | }
17 |
18 | return {
19 | createTweet,
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --theme-color: #f91680;
3 | --faded-theme-color: #f916803c;
4 | }
5 |
6 | * {
7 | box-sizing: border-box;
8 | }
9 |
10 | body {
11 | margin: 0;
12 | background-color: black;
13 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
14 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
15 | sans-serif;
16 | -webkit-font-smoothing: antialiased;
17 | -moz-osx-font-smoothing: grayscale;
18 | }
19 |
20 | code {
21 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
22 | monospace;
23 | }
24 |
25 | button {
26 | border: none;
27 | background: none;
28 | cursor: pointer;
29 | text-align: left;
30 | }
31 |
32 | button:disabled {
33 | cursor: not-allowed;
34 | }
35 |
36 | h1,
37 | h2,
38 | h3,
39 | h4,
40 | h5,
41 | h6,
42 | p {
43 | margin: 0;
44 | }
45 |
46 | input,
47 | textarea {
48 | font-family: inherit;
49 | }
50 |
51 | span {
52 | display: block;
53 | }
54 |
55 | a {
56 | text-decoration: none;
57 | }
58 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root'));
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/HomePage.js:
--------------------------------------------------------------------------------
1 | import Layout from '../components/Layout'
2 | import HomeContent from '../components/Home/HomeContent'
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/Notifications.js:
--------------------------------------------------------------------------------
1 | import Layout from '../components/Layout'
2 | import NotificationContent from '../components/Notification/NotificationContent'
3 |
4 | export default function Notifications() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/Profile.js:
--------------------------------------------------------------------------------
1 | import Layout from '../components/Layout'
2 | import ProfileContent from '../components/Profile/ProfileContent'
3 |
4 | export default function Profile() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/StartPage.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import users from '../users'
4 | import { saveToStorage } from '../utils/storage'
5 |
6 | const Main = styled.main`
7 | background-color: black;
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | width: 100%;
12 | height: 100vh;
13 | flex-direction: column;
14 |
15 | h1 {
16 | text-align: center;
17 | color: white;
18 | font-size: 20px;
19 | margin-bottom: 20px;
20 | }
21 |
22 | .users {
23 | display: flex;
24 | align-items: center;
25 | justify-content: space-between;
26 | width: 300px;
27 | margin: 0 auto;
28 |
29 | &__user {
30 | display: flex;
31 | flex-direction: column;
32 | img {
33 | width: 50px;
34 | height: 50px;
35 | border-radius: 50%;
36 | margin-bottom: 5px;
37 | }
38 | .name {
39 | margin: 10px auto;
40 | color: white;
41 | text-align: center;
42 | }
43 | }
44 | }
45 | `
46 |
47 | export default function Startpage() {
48 | const onClickUser = (id) => {
49 | saveToStorage('user', id)
50 | window.location.href = '/home'
51 | }
52 |
53 | return (
54 |
55 | Select a user
56 |
57 | {users.map((u) => (
58 |
66 | ))}
67 |
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/src/pages/Thread.js:
--------------------------------------------------------------------------------
1 | import { Feed, useStreamContext } from 'react-activity-feed'
2 | import { useParams } from 'react-router-dom'
3 |
4 | import Layout from '../components/Layout'
5 | import ThreadContent from '../components/Thread/ThreadContent'
6 |
7 | const FEED_ENRICH_OPTIONS = {
8 | withRecentReactions: true,
9 | withOwnReactions: true,
10 | withReactionCounts: true,
11 | withOwnChildren: true,
12 | }
13 |
14 | export default function Thread() {
15 | const { user } = useStreamContext()
16 |
17 | const { user_id } = useParams()
18 |
19 | return (
20 |
21 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/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';
6 |
--------------------------------------------------------------------------------
/src/users.js:
--------------------------------------------------------------------------------
1 | const users = [
2 | {
3 | id: 'iamdillion',
4 | name: 'Dillion',
5 | image: 'https://dillionmegida.com/img/deee.jpg',
6 | bio: 'Just here, doing my thing. Developer advocate at @getstream_io',
7 | token: 'ENTER TOKEN FOR iamdillion',
8 | },
9 | {
10 | id: 'getstream_io',
11 | name: 'Stream',
12 | image: 'https://avatars.githubusercontent.com/u/8597527?s=200&v=4',
13 | bio: 'Deploy activity feeds and chat at scale with Stream – an API driven platform powering over a billion end users. Get started at http://getstream.io.',
14 | token: 'ENTER TOKEN FOR getstream_io',
15 | },
16 | {
17 | id: 'jake',
18 | name: 'Jake',
19 | image: 'https://picsum.photos/300/300',
20 | bio: 'Just Jake, nothing much',
21 | token: 'ENTER TOKEN FOR jake',
22 | },
23 | {
24 | id: 'joe',
25 | name: 'Joe',
26 | image: 'https://picsum.photos/200/200',
27 | bio: 'How are you?',
28 | token: 'ENTER TOKEN FOR joe',
29 | },
30 | {
31 | id: 'mike',
32 | name: 'Mike',
33 | image: 'https://picsum.photos/400/400',
34 | bio: 'I am mike here. I do things on #react and #javascript',
35 | token: 'ENTER TOKEN FOR mike',
36 | },
37 | ]
38 |
39 | export default users
40 |
--------------------------------------------------------------------------------
/src/utils/links.js:
--------------------------------------------------------------------------------
1 | export function generateTweetLink(actorId, tweetActivityId) {
2 | return `/${actorId}/status/${tweetActivityId}`
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/storage.js:
--------------------------------------------------------------------------------
1 | export const saveToStorage = (key, value) =>
2 | window.localStorage.setItem(key, value)
3 |
4 | export const getFromStorage = (key) => window.localStorage.getItem(key)
5 |
--------------------------------------------------------------------------------
/src/utils/string.js:
--------------------------------------------------------------------------------
1 | export function formatStringWithLink(text, linkClass, noLink = false) {
2 | // regex to match links, hashtags and mentions
3 | const regex = /((https?:\/\/\S*)|(#\S*))|(@\S*)/gi
4 |
5 | const modifiedText = text.replace(regex, (match) => {
6 | let url, label
7 |
8 | if (match.startsWith('#')) {
9 | // it is a hashtag
10 | url = match
11 | label = match
12 | } else if (match.startsWith('@')) {
13 | // it is a mention
14 | url = `/${match.replace('@', '')}`
15 | label = match
16 | } else {
17 | // it is a link
18 | url = match
19 | label = url.replace('https://', '')
20 | }
21 |
22 | const tag = noLink ? 'span' : 'a'
23 |
24 | return `<${tag} class="${
25 | noLink ? '' : linkClass
26 | }" href="${url}">${label}${tag}>`
27 | })
28 |
29 | return modifiedText
30 | }
31 |
--------------------------------------------------------------------------------