├── server ├── src │ ├── subscription │ │ ├── message.js │ │ ├── notification.js │ │ └── index.js │ ├── loaders │ │ ├── index.js │ │ ├── file.js │ │ └── user.js │ ├── models │ │ ├── seed.js │ │ ├── file.js │ │ ├── notification.js │ │ ├── message.js │ │ ├── index.js │ │ └── user.js │ ├── resolvers │ │ ├── index.js │ │ ├── authorization.js │ │ ├── notification.js │ │ ├── user.js │ │ └── message.js │ ├── schema │ │ ├── index.js │ │ ├── notification.js │ │ ├── user.js │ │ └── message.js │ ├── tests │ │ ├── message.spec.js │ │ ├── api.js │ │ └── user.spec.js │ ├── utils │ │ ├── upload.js │ │ └── seed.js │ └── index.js ├── uploads │ ├── audio │ │ └── test.mp3 │ └── images │ │ ├── avatar.jpg │ │ └── cover.jpg ├── .env.example ├── .prettierrc ├── .travis.yml ├── .gitignore ├── .babelrc ├── LICENSE └── package.json ├── client ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── .prettierrc ├── src │ ├── constants │ │ ├── history.js │ │ ├── paths.js │ │ └── routes.js │ ├── components │ │ ├── Microphone │ │ │ ├── microphone.css │ │ │ └── Microphone.js │ │ ├── Error │ │ │ └── Error.js │ │ ├── SignOutButton │ │ │ └── SignOutButton.js │ │ ├── Loading │ │ │ └── Loading.js │ │ ├── MessageDelete │ │ │ └── MessageDelete.js │ │ ├── MessageCreate │ │ │ └── MessageCreate.js │ │ ├── Autoplay │ │ │ └── Autoplay.js │ │ ├── UserCard │ │ │ └── UserCard.js │ │ ├── Messages │ │ │ └── Messages.js │ │ ├── WhoToFollow │ │ │ └── WhoToFollow.js │ │ ├── UsersTab │ │ │ └── UsersTab.js │ │ ├── Notifications │ │ │ └── Notifications.js │ │ └── Navigation │ │ │ └── Navigation.js │ ├── session │ │ ├── queries.js │ │ ├── withSession.js │ │ └── withAuthorization.js │ ├── pages │ │ ├── NotFound.js │ │ ├── Admin.js │ │ ├── RouteWithLayout.js │ │ ├── Layout.js │ │ ├── Notifications.js │ │ ├── Home.js │ │ ├── SignIn.js │ │ ├── SignUp.js │ │ └── Profile.js │ ├── theme │ │ ├── withTheme.js │ │ └── theme.js │ ├── graphql │ │ ├── schema.js │ │ ├── subscriptions.js │ │ ├── resolvers.js │ │ ├── mutations.js │ │ └── queries.js │ ├── utils │ │ └── customFetch.js │ ├── App.js │ └── index.js ├── .travis.yml ├── .gitignore ├── LICENSE └── package.json ├── screenshots ├── Screenshot_1.png ├── Screenshot_10.png ├── Screenshot_11.png ├── Screenshot_2.png ├── Screenshot_3.png ├── Screenshot_4.png ├── Screenshot_5.png ├── Screenshot_6.png ├── Screenshot_7.png ├── Screenshot_8.png └── Screenshot_9.png └── README.md /server/src/subscription/message.js: -------------------------------------------------------------------------------- 1 | export const CREATED = 'MESSAGE_CREATED'; 2 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /screenshots/Screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_1.png -------------------------------------------------------------------------------- /screenshots/Screenshot_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_10.png -------------------------------------------------------------------------------- /screenshots/Screenshot_11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_11.png -------------------------------------------------------------------------------- /screenshots/Screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_2.png -------------------------------------------------------------------------------- /screenshots/Screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_3.png -------------------------------------------------------------------------------- /screenshots/Screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_4.png -------------------------------------------------------------------------------- /screenshots/Screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_5.png -------------------------------------------------------------------------------- /screenshots/Screenshot_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_6.png -------------------------------------------------------------------------------- /screenshots/Screenshot_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_7.png -------------------------------------------------------------------------------- /screenshots/Screenshot_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_8.png -------------------------------------------------------------------------------- /screenshots/Screenshot_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/screenshots/Screenshot_9.png -------------------------------------------------------------------------------- /server/uploads/audio/test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/server/uploads/audio/test.mp3 -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | TEST_DATABASE_URL=mongodb://localhost:27017/audiotwitter 2 | SECRET=secret 3 | RESEED_DATABASE_MINUTES=5 -------------------------------------------------------------------------------- /server/uploads/images/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/server/uploads/images/avatar.jpg -------------------------------------------------------------------------------- /server/uploads/images/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nemanjam/audio-twitter/HEAD/server/uploads/images/cover.jpg -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 70, 6 | } -------------------------------------------------------------------------------- /client/src/constants/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | export default createBrowserHistory(); 3 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 70, 6 | } -------------------------------------------------------------------------------- /client/src/components/Microphone/microphone.css: -------------------------------------------------------------------------------- 1 | .MuiDialog-paperWidthSm { 2 | max-width: 600px; 3 | flex: 1; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/loaders/index.js: -------------------------------------------------------------------------------- 1 | import * as user from './user'; 2 | import * as file from './file'; 3 | 4 | export default { user, file }; 5 | -------------------------------------------------------------------------------- /server/src/subscription/notification.js: -------------------------------------------------------------------------------- 1 | export const CREATED = 'NOTIFICATION_CREATED'; 2 | export const NOT_SEEN_UPDATED = 'NOT_SEEN_UPDATED'; 3 | -------------------------------------------------------------------------------- /client/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm test 11 | -------------------------------------------------------------------------------- /server/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm test 11 | -------------------------------------------------------------------------------- /client/src/constants/paths.js: -------------------------------------------------------------------------------- 1 | export const UPLOADS_AUDIO_FOLDER = 2 | 'http://localhost:8000/uploads/audio/'; 3 | export const UPLOADS_IMAGES_FOLDER = 4 | 'http://localhost:8000/uploads/images/'; 5 | -------------------------------------------------------------------------------- /client/src/components/Error/Error.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Error = ({ error }) => ( 4 |
5 | {error.message} 6 |
7 | ); 8 | 9 | export default Error; 10 | -------------------------------------------------------------------------------- /client/src/session/queries.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_ME = gql` 4 | query { 5 | me { 6 | id 7 | username 8 | email 9 | role 10 | } 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /server/src/loaders/file.js: -------------------------------------------------------------------------------- 1 | export const batchFiles = async (keys, models) => { 2 | const files = await models.File.find({ 3 | _id: { 4 | $in: keys, 5 | }, 6 | }); 7 | return keys.map(key => files.find(file => file.id == key)); 8 | }; 9 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | logfile 2 | 3 | .env 4 | 5 | node_modules/ 6 | 7 | uploads/* 8 | !uploads/audio 9 | uploads/audio/* 10 | !uploads/audio/test.mp3 11 | 12 | !uploads/images 13 | uploads/images/* 14 | !uploads/images/avatar.jpg 15 | !uploads/images/cover.jpg -------------------------------------------------------------------------------- /server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-proposal-optional-chaining"] 13 | } 14 | -------------------------------------------------------------------------------- /client/src/pages/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound = () => ( 4 |
5 |

404 page not found

6 |

7 | We are sorry but the page you are looking for does not exist. 8 |

9 |
10 | ); 11 | 12 | export default NotFound; 13 | -------------------------------------------------------------------------------- /client/src/constants/routes.js: -------------------------------------------------------------------------------- 1 | export const HOME = '/'; 2 | export const SIGN_UP = '/signup'; 3 | export const SIGN_IN = '/signin'; 4 | export const PROFILE = '/profile'; 5 | export const ADMIN = '/admin'; 6 | export const NOTFOUND = '/notfound'; 7 | export const USERNAME = '/:username'; 8 | export const NOTIFICATIONS = '/notifications'; 9 | -------------------------------------------------------------------------------- /server/src/subscription/index.js: -------------------------------------------------------------------------------- 1 | import { PubSub } from 'apollo-server'; 2 | 3 | import * as MESSAGE_EVENTS from './message'; 4 | import * as NOTIFICATION_EVENTS from './notification'; 5 | 6 | export const EVENTS = { 7 | MESSAGE: MESSAGE_EVENTS, 8 | NOTIFICATION: NOTIFICATION_EVENTS, 9 | }; 10 | 11 | export default new PubSub(); 12 | -------------------------------------------------------------------------------- /client/src/pages/Admin.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import withAuthorization from '../session/withAuthorization'; 4 | 5 | const AdminPage = () => ( 6 |
7 |

Admin Page

8 |
9 | ); 10 | 11 | //session je data iz me queryja, data.me 12 | export default withAuthorization( 13 | session => session?.me?.role === 'ADMIN', 14 | )(AdminPage); 15 | -------------------------------------------------------------------------------- /server/src/models/seed.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const seedSchema = new mongoose.Schema( 4 | { 5 | seed: { 6 | type: String, 7 | default: 'seed', 8 | required: true, 9 | }, 10 | }, 11 | { 12 | timestamps: true, 13 | }, 14 | ); 15 | 16 | const Seed = mongoose.model('Seed', seedSchema); 17 | 18 | export default Seed; 19 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /server/src/loaders/user.js: -------------------------------------------------------------------------------- 1 | // BATCHING JE: loader pretvara puno manjih sql upit u jedan slozeniji 2 | // cacheing je caching 3 | export const batchUsers = async (keys, models) => { 4 | const users = await models.User.find({ 5 | _id: { 6 | $in: keys, 7 | }, 8 | }); 9 | // isti redosled usera kao u keys 10 | return keys.map(key => users.find(user => user.id == key)); 11 | }; 12 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /server/src/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import { GraphQLDateTime } from 'graphql-iso-date'; 2 | 3 | import userResolvers from './user'; 4 | import messageResolvers from './message'; 5 | import notificationResolvers from './notification'; 6 | 7 | const customScalarResolver = { 8 | Date: GraphQLDateTime, 9 | }; 10 | 11 | export default [ 12 | customScalarResolver, // datum umesto stringa broja sekundi, Date scalar 13 | userResolvers, 14 | messageResolvers, 15 | notificationResolvers, 16 | ]; 17 | -------------------------------------------------------------------------------- /server/src/models/file.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const fileSchema = new mongoose.Schema({ 4 | filename: { 5 | type: String, 6 | required: [true, 'The filename is necessary'], 7 | }, 8 | mimetype: { 9 | type: String, 10 | required: [true, 'The mimetype is necessary'], 11 | }, 12 | path: { 13 | type: String, 14 | required: [true, 'The path is necessary'], 15 | }, 16 | }); 17 | 18 | const File = mongoose.model('File', fileSchema); 19 | 20 | export default File; 21 | -------------------------------------------------------------------------------- /client/src/pages/RouteWithLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | 4 | const RouteWithLayout = ({ 5 | layout: Layout, 6 | component: Component, 7 | layoutProps, 8 | componentProps, 9 | ...rest 10 | }) => { 11 | return ( 12 | { 15 | return ( 16 | 17 | 18 | 19 | ); 20 | }} 21 | /> 22 | ); 23 | }; 24 | 25 | export default RouteWithLayout; 26 | -------------------------------------------------------------------------------- /client/src/session/withSession.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import { useQuery } from '@apollo/react-hooks'; 4 | import { GET_ME } from './queries'; 5 | 6 | const withSession = Component => props => { 7 | const { data, loading, error, refetch } = useQuery(GET_ME); 8 | 9 | if (loading) { 10 | return null; 11 | } 12 | 13 | // console.log('withSession', data); 14 | // if (error) { 15 | // localStorage.removeItem('token'); 16 | // } 17 | 18 | return ; 19 | }; 20 | 21 | export default withSession; 22 | -------------------------------------------------------------------------------- /client/src/theme/withTheme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | import { ThemeProvider } from '@material-ui/styles'; 4 | import { GET_THEME } from '../graphql/queries'; 5 | 6 | const withTheme = getThemeFn => Component => props => { 7 | const { data, error, loading } = useQuery(GET_THEME); 8 | 9 | if (loading) return ''; 10 | 11 | const { 12 | theme: { type, color }, 13 | } = data; 14 | 15 | const theme = getThemeFn(type, color); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default withTheme; 25 | -------------------------------------------------------------------------------- /server/src/schema/index.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | import userSchema from './user'; 4 | import messageSchema from './message'; 5 | import notificationSchema from './notification'; 6 | 7 | const linkSchema = gql` 8 | scalar Date 9 | 10 | type File { 11 | id: ID! 12 | path: String! 13 | filename: String! 14 | mimetype: String! 15 | } 16 | 17 | type Query { 18 | _: Boolean 19 | } 20 | 21 | type Mutation { 22 | _: Boolean 23 | } 24 | 25 | type Subscription { 26 | _: Boolean 27 | } 28 | `; 29 | 30 | export default [ 31 | linkSchema, 32 | userSchema, 33 | messageSchema, 34 | notificationSchema, 35 | ]; 36 | -------------------------------------------------------------------------------- /server/src/schema/notification.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | extend type Query { 5 | notifications(cursor: String, limit: Int): NotificationConnection! 6 | notSeenNotificationsCount: Int! 7 | } 8 | 9 | type NotificationConnection { 10 | edges: [Notification!]! 11 | pageInfo: PageInfo! 12 | } 13 | 14 | type Notification { 15 | id: ID! 16 | createdAt: Date! 17 | user: User! 18 | action: String! 19 | } 20 | 21 | extend type Subscription { 22 | notificationCreated: NotificationCreated! 23 | notSeenUpdated: Int! 24 | } 25 | 26 | type NotificationCreated { 27 | notification: Notification! 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /client/src/components/SignOutButton/SignOutButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useApolloClient } from '@apollo/react-hooks'; 3 | 4 | import Button from '@material-ui/core/Button'; 5 | 6 | import * as routes from '../../constants/routes'; 7 | import history from '../../constants/history'; 8 | 9 | const SignOutButton = () => { 10 | const client = useApolloClient(); 11 | 12 | return ( 13 | 16 | ); 17 | }; 18 | 19 | const signOut = client => { 20 | localStorage.removeItem('token'); 21 | client.resetStore(); 22 | history.push(routes.HOME); 23 | }; 24 | 25 | export { signOut }; 26 | 27 | export default SignOutButton; 28 | -------------------------------------------------------------------------------- /client/src/session/withAuthorization.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | 4 | import { Redirect } from 'react-router-dom'; 5 | 6 | import * as routes from '../constants/routes'; 7 | import { GET_ME } from './queries'; 8 | 9 | // withAuthorization(condition)(Component)(props) 10 | // hoc fja koja prima komponentu kao arg ili vraca 11 | 12 | const withAuthorization = conditionFn => Component => props => { 13 | const { data, loading } = useQuery(GET_ME); 14 | 15 | if (loading) { 16 | return null; 17 | } 18 | 19 | return conditionFn(data) ? ( 20 | 21 | ) : ( 22 | 23 | ); 24 | }; 25 | 26 | export default withAuthorization; 27 | -------------------------------------------------------------------------------- /client/src/components/Loading/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Backdrop from '@material-ui/core/Backdrop'; 3 | import CircularProgress from '@material-ui/core/CircularProgress'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | backdrop: { 8 | zIndex: theme.zIndex.drawer + 1, 9 | color: '#fff', 10 | marginTop: theme.spacing(8), 11 | }, 12 | flex: { 13 | display: 'flex', 14 | justifyContent: 'center', 15 | }, 16 | })); 17 | 18 | function Loading() { 19 | const classes = useStyles(); 20 | 21 | return ( 22 |
23 | 24 |
25 | ); 26 | } 27 | 28 | export default Loading; 29 | 30 | /* 31 | 32 | 33 | 34 | */ 35 | -------------------------------------------------------------------------------- /server/src/models/notification.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const notificationSchema = new mongoose.Schema( 4 | { 5 | action: { 6 | type: String, 7 | required: true, 8 | }, 9 | isSeen: { 10 | type: Boolean, 11 | default: false, 12 | }, 13 | messageId: { 14 | type: mongoose.Schema.Types.ObjectId, 15 | ref: 'Message', 16 | }, 17 | // kome izlazi notifikacija 18 | ownerId: { 19 | type: mongoose.Schema.Types.ObjectId, 20 | ref: 'User', 21 | required: true, 22 | }, 23 | // ko mu je dao notifikaciju 24 | userId: { 25 | type: mongoose.Schema.Types.ObjectId, 26 | ref: 'User', 27 | required: true, 28 | }, 29 | }, 30 | { 31 | timestamps: true, 32 | }, 33 | ); 34 | 35 | const Notification = mongoose.model( 36 | 'Notification', 37 | notificationSchema, 38 | ); 39 | 40 | export default Notification; 41 | -------------------------------------------------------------------------------- /client/src/graphql/schema.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export default gql` 4 | type Autoplay { 5 | direction: String! 6 | createdAt: Date! 7 | duration: Int! 8 | } 9 | 10 | type Theme { 11 | type: String! 12 | color: String! 13 | } 14 | 15 | type MessagesVariables { 16 | username: String 17 | cursor: String 18 | limit: Int 19 | } 20 | 21 | type Query { 22 | autoplay: Autoplay! 23 | theme: Theme! 24 | refetchFollowers: Int! 25 | messagesVariables: MessagesVariables! 26 | } 27 | 28 | type Mutation { 29 | updateAutoplay( 30 | direction: String! 31 | createdAt: Date! 32 | duration: Int! 33 | ): Autoplay 34 | 35 | setTheme(type: String!, color: String!): Theme! 36 | setRefetchFollowers: Int! 37 | setMessagesVariables( 38 | username: String 39 | cursor: String 40 | limit: Int 41 | ): MessagesVariables! 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /client/src/pages/Layout.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | import CssBaseline from '@material-ui/core/CssBaseline'; 4 | import Container from '@material-ui/core/Container'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | 7 | import getThemeFn from '../theme/theme'; 8 | import withTheme from '../theme/withTheme'; 9 | 10 | import Navigation from '../components/Navigation/Navigation'; 11 | 12 | const useStyles = makeStyles(theme => ({ 13 | container: { 14 | padding: theme.spacing(2), 15 | paddingTop: theme.spacing(10), 16 | maxWidth: 1000, 17 | }, 18 | })); 19 | 20 | const Layout = ({ children, session, match }) => { 21 | const classes = useStyles(); 22 | 23 | return ( 24 | 25 | 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default withTheme(getThemeFn)(Layout); 33 | -------------------------------------------------------------------------------- /server/src/models/message.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const messageSchema = new mongoose.Schema( 4 | { 5 | fileId: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: 'File', 8 | required: true, 9 | }, 10 | userId: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: 'User', 13 | required: true, 14 | }, 15 | likesIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], 16 | isReposted: { 17 | type: mongoose.Schema.Types.Boolean, 18 | default: false, 19 | }, 20 | repost: { 21 | reposterId: { 22 | type: mongoose.Schema.Types.ObjectId, 23 | ref: 'User', 24 | }, 25 | originalMessageId: { 26 | type: mongoose.Schema.Types.ObjectId, 27 | ref: 'Message', 28 | }, 29 | }, 30 | }, 31 | { 32 | timestamps: true, 33 | }, 34 | ); 35 | 36 | const Message = mongoose.model('Message', messageSchema); 37 | 38 | export default Message; 39 | -------------------------------------------------------------------------------- /server/src/models/index.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | //mongoose.set('debug', true); 3 | 4 | import User from './user'; 5 | import Message from './message'; 6 | import File from './file'; 7 | import Notification from './notification'; 8 | import Seed from './seed'; 9 | 10 | const connectDb = () => { 11 | if (process.env.TEST_DATABASE_URL) { 12 | mongoose.connect(process.env.TEST_DATABASE_URL, { 13 | useNewUrlParser: true, 14 | useUnifiedTopology: true, 15 | useFindAndModify: false, 16 | }); 17 | mongoose.set('useCreateIndex', true); 18 | return mongoose.connection; 19 | } 20 | 21 | if (process.env.DATABASE_URL) { 22 | mongoose.connect(process.env.DATABASE_URL, { 23 | useUnifiedTopology: true, 24 | useNewUrlParser: true, 25 | useFindAndModify: false, 26 | }); 27 | mongoose.set('useCreateIndex', true); 28 | return mongoose.connection; 29 | } 30 | }; 31 | 32 | const models = { User, Message, File, Notification, Seed }; 33 | 34 | export { connectDb }; 35 | 36 | export default models; 37 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Robin Wieruch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Robin Wieruch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/components/MessageDelete/MessageDelete.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMutation } from '@apollo/react-hooks'; 3 | 4 | import { GET_ALL_MESSAGES_WITH_USERS } from '../../graphql/queries'; 5 | import { DELETE_MESSAGE } from '../../graphql/mutations'; 6 | 7 | const MessageDelete = ({ message }) => { 8 | const [deleteMessage] = useMutation(DELETE_MESSAGE, { 9 | update(cache) { 10 | const data = cache.readQuery({ 11 | query: GET_ALL_MESSAGES_WITH_USERS, 12 | }); 13 | 14 | cache.writeQuery({ 15 | query: GET_ALL_MESSAGES_WITH_USERS, 16 | data: { 17 | ...data, 18 | messages: { 19 | ...data.messages, 20 | edges: data.messages.edges.filter( 21 | node => node.id !== message.id, 22 | ), 23 | pageInfo: data.messages.pageInfo, 24 | }, 25 | }, 26 | }); 27 | }, 28 | }); 29 | 30 | const onClick = async () => { 31 | await deleteMessage({ 32 | variables: { messageId: message.id }, 33 | }); 34 | }; 35 | 36 | return ( 37 | 40 | ); 41 | }; 42 | 43 | export default MessageDelete; 44 | -------------------------------------------------------------------------------- /server/src/schema/user.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | extend type Query { 5 | friends( 6 | username: String! 7 | isFollowing: Boolean 8 | isFollowers: Boolean 9 | limit: Int 10 | ): [User!] 11 | whoToFollow(limit: Int): [User!] 12 | user(username: String!): User 13 | me: User 14 | } 15 | 16 | extend type Mutation { 17 | signUp( 18 | username: String! 19 | email: String! 20 | password: String! 21 | ): Token! 22 | 23 | signIn(login: String!, password: String!): Token! 24 | updateUser( 25 | name: String 26 | bio: String 27 | avatar: Upload 28 | cover: Upload 29 | ): User! 30 | deleteUser(id: ID!): Boolean! 31 | followUser(username: String!): Boolean! 32 | unfollowUser(username: String!): Boolean! 33 | } 34 | 35 | type Token { 36 | token: String! 37 | } 38 | 39 | type User { 40 | id: ID! 41 | username: String! 42 | email: String! 43 | role: String 44 | messages: [Message!] 45 | name: String 46 | bio: String 47 | avatar: File 48 | cover: File 49 | followers: [User!] 50 | following: [User!] 51 | followersCount: Int 52 | followingCount: Int 53 | messagesCount: Int 54 | isFollowHim: Boolean 55 | isFollowsMe: Boolean 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /client/src/pages/Notifications.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Grid from '@material-ui/core/Grid'; 6 | 7 | import NotificationsList from '../components/Notifications/Notifications'; 8 | import WhoToFollow from '../components/WhoToFollow/WhoToFollow'; 9 | import withAuthorization from '../session/withAuthorization'; 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | root: {}, 13 | })); 14 | 15 | const Notifications = ({ session }) => { 16 | const classes = useStyles(); 17 | 18 | return ( 19 | 25 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default withAuthorization(session => session?.me)( 47 | Notifications, 48 | ); 49 | -------------------------------------------------------------------------------- /server/src/schema/message.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | extend type Query { 5 | messages( 6 | cursor: String 7 | limit: Int 8 | username: String 9 | ): MessageConnection! 10 | message(id: ID!): Message! 11 | } 12 | 13 | extend type Mutation { 14 | createMessage(file: Upload!): Message! 15 | deleteMessage(messageId: ID!): Boolean! 16 | likeMessage(messageId: ID!): Boolean! 17 | unlikeMessage(messageId: ID!): Boolean! 18 | repostMessage(messageId: ID!): Boolean! 19 | unrepostMessage(messageId: ID!): Boolean! 20 | } 21 | 22 | type MessageConnection { 23 | edges: [Message!]! 24 | pageInfo: PageInfo! 25 | } 26 | 27 | type PageInfo { 28 | hasNextPage: Boolean! 29 | endCursor: String! 30 | } 31 | 32 | type Repost { 33 | reposter: User 34 | originalMessage: Message 35 | } 36 | 37 | type Message { 38 | id: ID! 39 | createdAt: Date! 40 | user: User! 41 | file: File! 42 | likesCount: Int! 43 | isLiked: Boolean! 44 | repostsCount: Int! 45 | isRepostedByMe: Boolean! 46 | isReposted: Boolean! 47 | repost: Repost 48 | } 49 | 50 | extend type Subscription { 51 | messageCreated(username: String): MessageCreated! 52 | } 53 | 54 | type MessageCreated { 55 | message: Message! 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-apollo-react-boilerplate-project", 3 | "version": "0.1.0", 4 | "author": "Robin Wieruch (https://www.robinwieruch.de)", 5 | "dependencies": { 6 | "@apollo/react-hooks": "^3.1.3", 7 | "@material-ui/core": "^4.8.1", 8 | "@material-ui/icons": "^4.5.1", 9 | "apollo-cache-inmemory": "^1.3.7", 10 | "apollo-client": "^2.6.8", 11 | "apollo-link": "^1.2.3", 12 | "apollo-link-error": "^1.1.1", 13 | "apollo-link-http": "^1.5.5", 14 | "apollo-link-ws": "^1.0.9", 15 | "apollo-upload-client": "^12.1.0", 16 | "apollo-utilities": "^1.0.24", 17 | "graphql": "^14.0.2", 18 | "graphql-tag": "^2.10.0", 19 | "history": "^4.7.2", 20 | "moment": "^2.24.0", 21 | "react": "^16.12.0", 22 | "react-dom": "^16.12.0", 23 | "react-mic": "^12.4.1", 24 | "react-router-dom": "^5.1.2", 25 | "react-scripts": "3.1.0", 26 | "subscriptions-transport-ws": "^0.9.15", 27 | "uuid": "^3.3.3", 28 | "wavesurfer.js": "^3.3.0" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test --env=jsdom --passWithNoTests", 34 | "eject": "react-scripts eject" 35 | }, 36 | "browserslist": [ 37 | ">0.2%", 38 | "not dead", 39 | "not ie <= 11", 40 | "not op_mini all" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /client/src/graphql/subscriptions.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const MESSAGE_CREATED = gql` 4 | subscription($username: String) { 5 | messageCreated(username: $username) { 6 | message { 7 | id 8 | createdAt 9 | likesCount 10 | isLiked 11 | repostsCount 12 | isReposted 13 | isRepostedByMe 14 | repost { 15 | reposter { 16 | id 17 | username 18 | name 19 | } 20 | originalMessage { 21 | id 22 | createdAt 23 | } 24 | } 25 | user { 26 | id 27 | username 28 | name 29 | avatar { 30 | id 31 | path 32 | } 33 | } 34 | file { 35 | id 36 | path 37 | } 38 | } 39 | } 40 | } 41 | `; 42 | 43 | export const NOTIFICATION_CREATED = gql` 44 | subscription { 45 | notificationCreated { 46 | notification { 47 | id 48 | createdAt 49 | action 50 | user { 51 | id 52 | username 53 | name 54 | avatar { 55 | id 56 | path 57 | } 58 | } 59 | } 60 | } 61 | } 62 | `; 63 | 64 | export const NOT_SEEN_UPDATED = gql` 65 | subscription { 66 | notSeenUpdated 67 | } 68 | `; 69 | -------------------------------------------------------------------------------- /server/src/resolvers/authorization.js: -------------------------------------------------------------------------------- 1 | import { ForbiddenError } from 'apollo-server'; 2 | import { combineResolvers, skip } from 'graphql-resolvers'; //middleware, skip kao next() u express 3 | 4 | // ovo su sve resolveri, middleware, 5 | // da se makne logika iz pravih resolvera koji samo fetchuju data iz baze 6 | 7 | // whether the user is able to delete a message (permission-based authorization), samo svoju 8 | // and whether a user is able to delete a user (role-based authorization), rola admin 9 | 10 | // za create message, samo logovan moze da kreira message 11 | export const isAuthenticated = (parent, args, { me }) => 12 | me ? skip : new ForbiddenError('Not authenticated as user.'); 13 | 14 | // za delete user, samo admin moze da delete user 15 | export const isAdmin = combineResolvers( 16 | isAuthenticated, // ovde is auth middleware, ne u resolveru, da bi mogao me da ima 17 | (parent, args, { me: { role } }) => 18 | role === 'ADMIN' 19 | ? skip 20 | : new ForbiddenError('Not authorized as admin.'), 21 | ); 22 | 23 | // samo owner moze da delete svoju poruku 24 | export const isMessageOwner = async ( 25 | parent, 26 | { messageId }, 27 | { models, me }, 28 | ) => { 29 | const message = await models.Message.findById(messageId); 30 | 31 | if (!message.userId.equals(me.id)) { 32 | throw new ForbiddenError('Not authenticated as owner.'); 33 | } 34 | 35 | return skip; 36 | }; 37 | -------------------------------------------------------------------------------- /client/src/components/MessageCreate/MessageCreate.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useMutation } from '@apollo/react-hooks'; 3 | 4 | import ErrorMessage from '../Error/Error'; 5 | import { CREATE_MESSAGE } from '../../graphql/mutations'; 6 | 7 | const MessageCreate = () => { 8 | const [file, setFile] = useState(null); 9 | 10 | const [createMessage, { error }] = useMutation(CREATE_MESSAGE); 11 | 12 | const onChange = event => { 13 | const { name, value, files } = event.target; 14 | if (name === 'file') setFile(files[0]); 15 | }; 16 | 17 | const onSubmit = async event => { 18 | event.preventDefault(); 19 | 20 | try { 21 | await createMessage({ variables: { file } }); 22 | setFile(null); 23 | } catch (error) {} 24 | }; 25 | 26 | return ( 27 |
onSubmit(event)}> 28 | 29 | 30 | 31 | {error && } 32 | 33 | ); 34 | }; 35 | 36 | export default MessageCreate; 37 | 38 | // Not used anymore because of Subscription 39 | 40 | // update={(cache, { data: { createMessage } }) => { 41 | // const data = cache.readQuery({ 42 | // query: GET_ALL_MESSAGES_WITH_USERS, 43 | // }); 44 | 45 | // cache.writeQuery({ 46 | // query: GET_ALL_MESSAGES_WITH_USERS, 47 | // data: { 48 | // ...data, 49 | // messages: { 50 | // ...data.messages, 51 | // edges: [createMessage, ...data.messages.edges], 52 | // pageInfo: data.messages.pageInfo, 53 | // }, 54 | // }, 55 | // }); 56 | // }} 57 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-apollo-express-postgresql-boilerplate", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "10.11.0" 8 | }, 9 | "scripts": { 10 | "start": "nodemon --exec babel-node src/index.js", 11 | "test:run-server": "TEST_DATABASE_URL=mongodb://localhost:27017/mytestdatabase npm start", 12 | "test:execute-test": "TEST_DATABASE_URL=mongodb://localhost:27017/mytestdatabase mocha --require @babel/register 'src/**/*.spec.js'", 13 | "test": "echo \"No test specified\" && exit 0" 14 | }, 15 | "keywords": [], 16 | "author": "Robin Wieruch (https://www.robinwieruch.de)", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@babel/core": "^7.1.6", 20 | "@babel/node": "^7.0.0", 21 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 22 | "@babel/preset-env": "^7.1.6", 23 | "@babel/register": "^7.0.0", 24 | "axios": "^0.18.1", 25 | "chai": "^4.2.0", 26 | "mocha": "^5.2.0", 27 | "morgan": "^1.9.1", 28 | "nodemon": "^1.18.7" 29 | }, 30 | "dependencies": { 31 | "apollo-server": "^2.9.15", 32 | "apollo-server-express": "^2.9.15", 33 | "bcryptjs": "^2.4.3", 34 | "cors": "^2.8.5", 35 | "dataloader": "^1.4.0", 36 | "dotenv": "^6.1.0", 37 | "express": "^4.16.4", 38 | "faker": "^4.1.0", 39 | "graphql": "^14.0.2", 40 | "graphql-iso-date": "^3.6.1", 41 | "graphql-resolvers": "^0.3.2", 42 | "jsonwebtoken": "^8.4.0", 43 | "moment": "^2.24.0", 44 | "mongoose": "^5.8.3", 45 | "uuid": "^3.3.2", 46 | "validator": "^10.9.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/pages/Home.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import Grid from '@material-ui/core/Grid'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | import MessageCreate from '../components/MessageCreate/MessageCreate'; 7 | import Microphone from '../components/Microphone/Microphone'; 8 | import Messages from '../components/Messages/Messages'; 9 | import Autoplay from '../components/Autoplay/Autoplay'; 10 | import WhoToFollow from '../components/WhoToFollow/WhoToFollow'; 11 | 12 | const useStyles = makeStyles(theme => ({ 13 | root: {}, 14 | item: { width: '100%' }, 15 | })); 16 | 17 | const Home = ({ session }) => { 18 | const classes = useStyles(); 19 | const [mainAutoplay, setMainAutoplay] = useState('none'); 20 | 21 | return ( 22 | <> 23 | 29 | 39 | 40 | 41 | 42 | {session?.me && ( 43 | 44 | 45 | 46 | )} 47 | 48 | 49 | 50 | 51 | 52 | {session?.me && } 53 | 54 | ); 55 | }; 56 | 57 | export default Home; 58 | -------------------------------------------------------------------------------- /server/src/tests/message.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import * as api from './api'; 4 | import models, { connectDb } from '../models'; 5 | 6 | let db; 7 | 8 | before(async () => { 9 | db = await connectDb('mongodb://localhost:27017/mytestdatabase'); 10 | }); 11 | 12 | after(async () => { 13 | await db.connection.close(); 14 | }); 15 | 16 | describe('Messages', () => { 17 | describe('messages (limit: INT)', () => { 18 | it('returns a list of messages', async () => { 19 | const expectedResult = { 20 | data: { 21 | messages: { 22 | edges: [ 23 | { 24 | text: 'Published a complete ...', 25 | }, 26 | { 27 | text: 'Happy to release ...', 28 | }, 29 | ], 30 | }, 31 | }, 32 | }; 33 | 34 | const result = await api.messages(); 35 | 36 | expect(result.data).to.eql(expectedResult); 37 | }); 38 | 39 | it('should get messages with the users', async () => { 40 | const expectedResult = { 41 | data: { 42 | messages: { 43 | edges: [ 44 | { 45 | text: 'Published a complete ...', 46 | user: { 47 | username: 'ddavids', 48 | }, 49 | }, 50 | { 51 | text: 'Happy to release ...', 52 | user: { 53 | username: 'ddavids', 54 | }, 55 | }, 56 | ], 57 | }, 58 | }, 59 | }; 60 | 61 | const result = await api.messagesInclUsers(); 62 | 63 | expect(result.data).to.eql(expectedResult); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /client/src/theme/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import blue from '@material-ui/core/colors/blue'; 3 | import green from '@material-ui/core/colors/green'; 4 | import red from '@material-ui/core/colors/red'; 5 | import deepOrange from '@material-ui/core/colors/deepOrange'; 6 | import lightBlue from '@material-ui/core/colors/lightBlue'; 7 | 8 | const getThemeFn = (type, color) => { 9 | const themeVariable = { 10 | type: type === 'light' ? 'light' : 'dark', 11 | background: 12 | type === 'light' ? { default: 'rgb(230, 236, 240)' } : {}, 13 | primaryMain: 14 | color === 'green' ? green['A400'] : deepOrange['A400'], 15 | secondaryMain: 16 | color === 'green' ? red['A400'] : lightBlue['A400'], 17 | }; 18 | 19 | const theme = createMuiTheme({ 20 | palette: { 21 | type: themeVariable.type, 22 | primary: { 23 | // light: will be calculated from palette.primary.main, 24 | main: themeVariable.primaryMain, //lightGreen['A400'], // '#ff4400' orange 25 | // dark: will be calculated from palette.primary.main, 26 | // contrastText: will be calculated to contrast with palette.primary.main 27 | }, 28 | background: themeVariable.background, 29 | secondary: { 30 | // light: blue['A200'], // '#0066ff' 31 | main: themeVariable.secondaryMain, //red['A400'], // '#0044ff' 32 | // dark: will be calculated from palette.secondary.main, 33 | // contrastText: '#ffcc00', 34 | }, 35 | // Used by `getContrastText()` to maximize the contrast between 36 | // the background and the text. 37 | contrastThreshold: 3, 38 | // Used by the functions below to shift a color's luminance by approximately 39 | // two indexes within its tonal palette. 40 | // E.g., shift from Red 500 to Red 300 or Red 700. 41 | tonalOffset: 0.2, 42 | }, 43 | }); 44 | 45 | // console.log(theme); 46 | return theme; 47 | }; 48 | 49 | export default getThemeFn; 50 | -------------------------------------------------------------------------------- /client/src/utils/customFetch.js: -------------------------------------------------------------------------------- 1 | const parseHeaders = rawHeaders => { 2 | const headers = new Headers(); 3 | // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space 4 | // https://tools.ietf.org/html/rfc7230#section-3.2 5 | const preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' '); 6 | preProcessedHeaders.split(/\r?\n/).forEach(line => { 7 | const parts = line.split(':'); 8 | const key = parts.shift().trim(); 9 | if (key) { 10 | const value = parts.join(':').trim(); 11 | headers.append(key, value); 12 | } 13 | }); 14 | return headers; 15 | }; 16 | 17 | const uploadFetch = (url, options) => 18 | new Promise((resolve, reject) => { 19 | const xhr = new XMLHttpRequest(); 20 | xhr.onload = () => { 21 | const opts = { 22 | status: xhr.status, 23 | statusText: xhr.statusText, 24 | headers: parseHeaders(xhr.getAllResponseHeaders() || ''), 25 | }; 26 | opts.url = 27 | 'responseURL' in xhr 28 | ? xhr.responseURL 29 | : opts.headers.get('X-Request-URL'); 30 | const body = 31 | 'response' in xhr ? xhr.response : xhr.responseText; 32 | resolve(new Response(body, opts)); 33 | }; 34 | xhr.onerror = () => { 35 | reject(new TypeError('Network request failed')); 36 | }; 37 | xhr.ontimeout = () => { 38 | reject(new TypeError('Network request failed')); 39 | }; 40 | xhr.open(options.method, url, true); 41 | 42 | Object.keys(options.headers).forEach(key => { 43 | xhr.setRequestHeader(key, options.headers[key]); 44 | }); 45 | 46 | if (xhr.upload) { 47 | xhr.upload.onprogress = options.onProgress; 48 | } 49 | 50 | options.onAbortPossible(() => { 51 | xhr.abort(); 52 | }); 53 | 54 | xhr.send(options.body); 55 | }); 56 | 57 | export const customFetch = (uri, options) => { 58 | if (options.useUpload) { 59 | return uploadFetch(uri, options); 60 | } 61 | return fetch(uri, options); 62 | }; 63 | -------------------------------------------------------------------------------- /server/src/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import bcrypt from 'bcryptjs'; 4 | import isEmail from 'validator/lib/isEmail'; 5 | 6 | const userSchema = new mongoose.Schema({ 7 | username: { 8 | type: String, 9 | unique: true, 10 | required: true, 11 | }, 12 | email: { 13 | type: String, 14 | unique: true, 15 | required: true, 16 | validate: [isEmail, 'No valid email address provided.'], 17 | }, 18 | password: { 19 | type: String, 20 | required: true, 21 | minlength: 7, 22 | maxlength: 60, 23 | }, 24 | role: { 25 | type: String, 26 | }, 27 | name: { 28 | type: String, 29 | }, 30 | bio: { 31 | type: String, 32 | }, 33 | avatarId: { type: mongoose.Schema.Types.ObjectId, ref: 'File' }, 34 | coverId: { type: mongoose.Schema.Types.ObjectId, ref: 'File' }, 35 | followersIds: [ 36 | { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 37 | ], 38 | followingIds: [ 39 | { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 40 | ], 41 | }); 42 | 43 | userSchema.statics.findByLogin = async function(login) { 44 | let user = await this.findOne({ 45 | username: login, 46 | }); 47 | 48 | if (!user) { 49 | user = await this.findOne({ email: login }); 50 | } 51 | 52 | return user; 53 | }; 54 | 55 | userSchema.pre('remove', async function(next) { 56 | await this.model('User').update( 57 | {}, 58 | { $pull: { followersIds: this._id, followingIds: this._id } }, 59 | { multi: true }, 60 | ); 61 | this.model('Message').deleteMany({ userId: this._id }, next); 62 | }); 63 | 64 | userSchema.pre('save', async function() { 65 | this.password = await this.generatePasswordHash(); 66 | }); 67 | 68 | userSchema.methods.generatePasswordHash = async function() { 69 | const saltRounds = 10; 70 | return await bcrypt.hash(this.password, saltRounds); 71 | }; 72 | 73 | userSchema.methods.validatePassword = async function(password) { 74 | return await bcrypt.compare(password, this.password); 75 | }; 76 | 77 | const User = mongoose.model('User', userSchema); 78 | 79 | export default User; 80 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | React App 26 | 27 | 28 | 31 | 32 | 36 | 37 | 41 |
42 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /server/src/utils/upload.js: -------------------------------------------------------------------------------- 1 | import { resolve, join } from 'path'; 2 | import { createWriteStream, unlinkSync } from 'fs'; 3 | import { sync } from 'mkdirp'; 4 | import uuidv4 from 'uuid/v4'; 5 | import File from '../models/file'; 6 | 7 | const uploadAudioDir = resolve(__dirname, '../../uploads/audio'); 8 | const uploadImagesDir = resolve(__dirname, '../../uploads/images'); 9 | 10 | // Ensure upload directory exists. 11 | sync(uploadAudioDir); 12 | sync(uploadImagesDir); 13 | 14 | const storeDB = args => { 15 | const { filename, mimetype, path } = args; 16 | 17 | try { 18 | const file = new File({ 19 | filename, 20 | mimetype, 21 | path, 22 | }); 23 | return file.save(); 24 | } catch (err) { 25 | return err; 26 | } 27 | }; 28 | 29 | const storeFS = ({ stream, filename, mimetype }) => { 30 | const id = uuidv4(); 31 | let path; 32 | if (mimetype.includes('audio')) 33 | path = join(uploadAudioDir, `${id}-${filename}`); 34 | else path = join(uploadImagesDir, `${id}-${filename}`); 35 | 36 | const webPath = `${id}-${filename}`; 37 | 38 | return new Promise((resolve, reject) => 39 | stream 40 | .on('error', error => { 41 | if (stream.truncated) unlinkSync(path); 42 | reject(error); 43 | }) 44 | .pipe(createWriteStream(path)) 45 | .on('error', error => reject(error)) 46 | .on('finish', () => resolve({ webPath })), 47 | ); 48 | }; 49 | 50 | export const processFile = async file => { 51 | const fileData = await file; 52 | // console.log(fileData); 53 | const { createReadStream, filename, mimetype } = fileData; 54 | const stream = createReadStream(); 55 | const { webPath } = await storeFS({ stream, filename, mimetype }); 56 | return storeDB({ filename, mimetype, path: webPath }); 57 | }; 58 | 59 | export const deleteFile = fileName => { 60 | const isImage = 61 | fileName.endsWith('.jpg') || 62 | fileName.endsWith('.jpeg') || 63 | fileName.endsWith('.png'); 64 | 65 | const dir = isImage ? uploadImagesDir : uploadAudioDir; 66 | 67 | try { 68 | const path = join(dir, fileName); //image or audio folders 69 | console.log(`delting file: ${path}`); 70 | unlinkSync(path); 71 | } catch (err) { 72 | console.error(err); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Switch } from 'react-router-dom'; 3 | 4 | import HomePage from './pages/Home'; 5 | import SignUpPage from './pages/SignUp'; 6 | import SignInPage from './pages/SignIn'; 7 | import ProfilePage from './pages/Profile'; 8 | import AdminPage from './pages/Admin'; 9 | import NotFoundPage from './pages/NotFound'; 10 | import NotificationsPage from './pages/Notifications'; 11 | import RouteWithLayout from './pages/RouteWithLayout'; 12 | import Layout from './pages/Layout'; 13 | import withSession from './session/withSession'; 14 | 15 | import * as routes from './constants/routes'; 16 | 17 | const App = ({ session, refetch }) => ( 18 | 19 | 20 | 28 | 35 | 42 | 48 | 54 | 61 | 69 | 70 | 71 | ); 72 | 73 | //samo ovde withSession, pa niz tree 74 | export default withSession(App); 75 | -------------------------------------------------------------------------------- /client/src/graphql/resolvers.js: -------------------------------------------------------------------------------- 1 | import { GET_AUTOPLAY } from './queries'; 2 | 3 | export default { 4 | Query: { 5 | // autoplay: (_root, _args, { cache }) => { 6 | // const autoplay = cache.readQuery({ query: GET_AUTOPLAY }); 7 | // console.log('autoplay resolver', autoplay); 8 | // return autoplay; 9 | // }, 10 | // ima (always: true) u schemi da ne bi citao cache 11 | theme: (_root, _args, { cache }) => { 12 | let data = JSON.parse(localStorage.getItem('theme')); 13 | // console.log('theme resolver', theme); 14 | if (!data) { 15 | data = { 16 | theme: { 17 | __typename: 'Theme', 18 | type: 'light', 19 | color: 'orange', 20 | }, 21 | }; 22 | } 23 | return data.theme; 24 | }, 25 | }, 26 | Mutation: { 27 | updateAutoplay: ( 28 | _root, 29 | { direction, createdAt, duration }, 30 | { cache, getCacheKey }, 31 | ) => { 32 | // const previous = cache.readQuery({ query: GET_AUTOPLAY }); 33 | // console.log('previous', previous); 34 | //to je fora, mora autoplay, vidis format cache objekta sa readQuery 35 | const data = { 36 | autoplay: { 37 | direction, 38 | createdAt, 39 | duration, 40 | __typename: 'Autoplay', 41 | }, 42 | }; 43 | cache.writeData({ data }); 44 | }, 45 | setTheme: (_root, { type, color }, { cache, getCacheKey }) => { 46 | const data = { 47 | theme: { 48 | type, 49 | color, 50 | __typename: 'Theme', 51 | }, 52 | }; 53 | localStorage.setItem('theme', JSON.stringify(data)); 54 | cache.writeData({ data }); 55 | }, 56 | setRefetchFollowers: (_root, args, { cache, getCacheKey }) => { 57 | const data = { 58 | refetchFollowers: { 59 | signal: Math.floor(Math.random() * 1000), 60 | __typename: 'Int', 61 | }, 62 | }; 63 | cache.writeData({ data }); 64 | }, 65 | setMessagesVariables: ( 66 | _root, 67 | { username, cursor, limit }, 68 | { cache, getCacheKey }, 69 | ) => { 70 | const data = { 71 | messagesVariables: { 72 | username, 73 | cursor, 74 | limit, 75 | __typename: 'MessagesVariables', 76 | }, 77 | }; 78 | cache.writeData({ data }); 79 | }, 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/graphql/mutations.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const CREATE_MESSAGE = gql` 4 | mutation($file: Upload!) { 5 | createMessage(file: $file) { 6 | id 7 | createdAt 8 | user { 9 | id 10 | username 11 | } 12 | file { 13 | id 14 | path 15 | } 16 | } 17 | } 18 | `; 19 | 20 | export const UPDATE_USER = gql` 21 | mutation( 22 | $name: String 23 | $bio: String 24 | $avatar: Upload 25 | $cover: Upload 26 | ) { 27 | updateUser( 28 | name: $name 29 | bio: $bio 30 | avatar: $avatar 31 | cover: $cover 32 | ) { 33 | id 34 | username 35 | name 36 | bio 37 | avatar { 38 | id 39 | path 40 | } 41 | cover { 42 | id 43 | path 44 | } 45 | } 46 | } 47 | `; 48 | 49 | export const DELETE_MESSAGE = gql` 50 | mutation($messageId: ID!) { 51 | deleteMessage(messageId: $messageId) 52 | } 53 | `; 54 | 55 | export const SIGN_IN = gql` 56 | mutation($login: String!, $password: String!) { 57 | signIn(login: $login, password: $password) { 58 | token 59 | } 60 | } 61 | `; 62 | 63 | export const SIGN_UP = gql` 64 | mutation($username: String!, $email: String!, $password: String!) { 65 | signUp(username: $username, email: $email, password: $password) { 66 | token 67 | } 68 | } 69 | `; 70 | 71 | export const FOLLOW_USER = gql` 72 | mutation($username: String!) { 73 | followUser(username: $username) 74 | } 75 | `; 76 | 77 | export const UNFOLLOW_USER = gql` 78 | mutation($username: String!) { 79 | unfollowUser(username: $username) 80 | } 81 | `; 82 | 83 | export const LIKE_MESSAGE = gql` 84 | mutation($messageId: ID!) { 85 | likeMessage(messageId: $messageId) 86 | } 87 | `; 88 | 89 | export const UNLIKE_MESSAGE = gql` 90 | mutation($messageId: ID!) { 91 | unlikeMessage(messageId: $messageId) 92 | } 93 | `; 94 | 95 | export const REPOST_MESSAGE = gql` 96 | mutation($messageId: ID!) { 97 | repostMessage(messageId: $messageId) 98 | } 99 | `; 100 | 101 | export const UNREPOST_MESSAGE = gql` 102 | mutation($messageId: ID!) { 103 | unrepostMessage(messageId: $messageId) 104 | } 105 | `; 106 | 107 | export const UPDATE_AUTOPLAY = gql` 108 | mutation($direction: String!, $createdAt: Date!, $duration: Int!) { 109 | updateAutoplay( 110 | direction: $direction 111 | createdAt: $createdAt 112 | duration: $duration 113 | ) @client 114 | } 115 | `; 116 | 117 | export const SET_THEME = gql` 118 | mutation($type: String!, $color: String!) { 119 | setTheme(type: $type, color: $color) @client 120 | } 121 | `; 122 | 123 | export const SET_REFETCH_FOLLOWERS = gql` 124 | mutation { 125 | setRefetchFollowers @client 126 | } 127 | `; 128 | 129 | export const SET_MESSAGES_VARIABLES = gql` 130 | mutation($username: String, $cursor: String, $limit: Int) { 131 | setMessagesVariables( 132 | username: $username 133 | cursor: $cursor 134 | limit: $limit 135 | ) @client 136 | } 137 | `; 138 | -------------------------------------------------------------------------------- /server/src/tests/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = 'http://localhost:8000/graphql'; 4 | 5 | export const signIn = async variables => 6 | await axios.post(API_URL, { 7 | query: ` 8 | mutation ($login: String!, $password: String!) { 9 | signIn(login: $login, password: $password) { 10 | token 11 | } 12 | } 13 | `, 14 | variables, 15 | }); 16 | 17 | export const me = async token => 18 | await axios.post( 19 | API_URL, 20 | { 21 | query: ` 22 | { 23 | me { 24 | id 25 | email 26 | username 27 | } 28 | } 29 | `, 30 | }, 31 | token 32 | ? { 33 | headers: { 34 | 'x-token': token, 35 | }, 36 | } 37 | : null, 38 | ); 39 | 40 | export const user = async variables => 41 | axios.post(API_URL, { 42 | query: ` 43 | query ($id: ID!) { 44 | user(id: $id) { 45 | id 46 | username 47 | email 48 | role 49 | } 50 | } 51 | `, 52 | variables, 53 | }); 54 | 55 | export const users = async () => 56 | axios.post(API_URL, { 57 | query: ` 58 | { 59 | users { 60 | id 61 | username 62 | email 63 | role 64 | } 65 | } 66 | `, 67 | }); 68 | 69 | export const signUp = async variables => 70 | axios.post(API_URL, { 71 | query: ` 72 | mutation( 73 | $username: String!, 74 | $email: String!, 75 | $password: String! 76 | ) { 77 | signUp( 78 | username: $username, 79 | email: $email, 80 | password: $password 81 | ) { 82 | token 83 | } 84 | } 85 | `, 86 | variables, 87 | }); 88 | 89 | export const updateUser = async (variables, token) => 90 | axios.post( 91 | API_URL, 92 | { 93 | query: ` 94 | mutation ($username: String!) { 95 | updateUser(username: $username) { 96 | username 97 | } 98 | } 99 | `, 100 | variables, 101 | }, 102 | token 103 | ? { 104 | headers: { 105 | 'x-token': token, 106 | }, 107 | } 108 | : null, 109 | ); 110 | 111 | export const deleteUser = async (variables, token) => 112 | axios.post( 113 | API_URL, 114 | { 115 | query: ` 116 | mutation ($id: ID!) { 117 | deleteUser(id: $id) 118 | } 119 | `, 120 | variables, 121 | }, 122 | token 123 | ? { 124 | headers: { 125 | 'x-token': token, 126 | }, 127 | } 128 | : null, 129 | ); 130 | 131 | export const messages = async () => 132 | axios.post(API_URL, { 133 | query: ` 134 | query { 135 | messages (limit: 2) { 136 | edges { 137 | text 138 | } 139 | } 140 | } 141 | `, 142 | }); 143 | 144 | export const messagesInclUsers = async () => 145 | axios.post(API_URL, { 146 | query: ` 147 | query { 148 | messages (limit: 2) { 149 | edges { 150 | text 151 | user { 152 | username 153 | } 154 | } 155 | } 156 | } 157 | `, 158 | }); 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audio Twitter 2 | 3 | ## Twitter clone made with React, Apollo, MongoDB, Material-UI, Wavesurfer 4 | 5 | This is fullstack clone of Twitter with audio instead text messages. It is made for educational purposes only and it is not affiliated with Twitter in any way. 6 | 7 | ## Features 8 | 9 | - General functionalities 10 | 11 | - Record voice, preview oscilloscope, redo recording, preview recording, cancel recording, preview recorded waveform 12 | - Store audio files to server 13 | - Play/pause/stop audio messages with waveform preview 14 | - Autoplay existing messages, autoplay incoming messages 15 | - Limit autoplay duration to 5, 10, 15 or 20 seconds 16 | - Timeline feed of messages of the following users 17 | - Notifications feed, non-seen notifications count 18 | - Profile page with user's messages feed, following and followers lists 19 | - Social network functionalities: follow/unfollow users, like/unlike messages, repost/unrepost messages 20 | - Local state management with Apollo cache, without Redux 21 | - Edit avatar, cover, name and bio 22 | 23 | - Authentication 24 | 25 | - JWT auth on http and websocket links 26 | - Sign up with email/password, sign in 27 | - User/admin role 28 | - Protected routes with HOCs 29 | 30 | - Design 31 | 32 | - Material-UI responsive design 33 | - Choose between 4 different green/orange light/dark themes 34 | - Persist theme in local storage 35 | - Tabs navigation 36 | - Popover with user card 37 | 38 | - GraphQL 39 | 40 | - Queries User: users, user, me, whoToFollow, friends 41 | - Queries Message: messages, message 42 | - Queries Notification: notifications, notSeenNotificationsCount 43 | - Mutations User: signUp, signIn, updateUser, deleteUser, followUser, unfollowUser 44 | - Mutations Message: createMessage, deleteMessage, likeMessage, unlikeMessage, repostMessage, unrepostMessage 45 | - Subscriptions: messageCreated, notificationCreated, notSeenUpdated 46 | - Relay cursor paginations: Messages, Notifications 47 | - Loaders: File, User 48 | - Client Queries: autoplay, theme 49 | - Client Mutations: updateAutoplay, setTheme 50 | 51 | - Database 52 | - Mongoose Models: User, Message, File, Notification 53 | - Seed database with Faker 54 | 55 | ## Screenshots 56 | 57 | ![Screenshot1](/screenshots/Screenshot_1.png) 58 | 59 | ![Screenshot2](/screenshots/Screenshot_2.png) 60 | 61 | ![Screenshot3](/screenshots/Screenshot_3.png) 62 | 63 | ![Screenshot4](/screenshots/Screenshot_4.png) 64 | 65 | ![Screenshot5](/screenshots/Screenshot_5.png) 66 | 67 | ![Screenshot6](/screenshots/Screenshot_6.png) 68 | 69 | ![Screenshot7](/screenshots/Screenshot_7.png) 70 | 71 | ![Screenshot8](/screenshots/Screenshot_8.png) 72 | 73 | ![Screenshot9](/screenshots/Screenshot_9.png) 74 | 75 | ![Screenshot10](/screenshots/Screenshot_10.png) 76 | 77 | ![Screenshot11](/screenshots/Screenshot_11.png) 78 | 79 | ## Libraries used 80 | 81 | ### Client: 82 | 83 | - React `16.12` with functional components and Hooks 84 | - Material-UI `4.8` 85 | - Apollo Client `2.6`, Apollo Upload Client 86 | - React Mic, Wavesurfer.js `3.3` 87 | 88 | ### Server: 89 | 90 | - Apollo Server `2.9`, Apollo Server Express 91 | - Mongoose `5.8` 92 | - Faker, Dotenv, Babel 93 | 94 | ## Installation and running 95 | 96 | - `git clone git@github.com:nemanjam/audio-twitter.git` 97 | - `cd audio-twitter/client` 98 | - `cd audio-twitter/server` 99 | - `npm install` 100 | - rename `.env.example` to `.env` and set database url and JWT secret 101 | - `npm start` 102 | - visit `http://localhost:3000` for client and `http://localhost:8000` for server 103 | 104 | ## References 105 | 106 | - [Twitter](https://twitter.com) 107 | - Robin Wieruch [React Apollo boilerplate](https://github.com/the-road-to-graphql/fullstack-apollo-react-boilerplate) 108 | - Robin Wieruch [Node.js with Express + MongoDB boilerplate](https://github.com/the-road-to-graphql/fullstack-apollo-express-mongodb-boilerplate) 109 | - Apollo [docs](https://www.apollographql.com/docs/) 110 | - Material-UI [docs](https://material-ui.com/getting-started/installation/) 111 | 112 | ## Licence 113 | 114 | ### MIT 115 | -------------------------------------------------------------------------------- /server/src/resolvers/notification.js: -------------------------------------------------------------------------------- 1 | import mongoose, { models } from 'mongoose'; 2 | import { combineResolvers } from 'graphql-resolvers'; 3 | import { withFilter } from 'apollo-server'; 4 | 5 | import pubsub, { EVENTS } from '../subscription'; 6 | import { isAuthenticated } from './authorization'; 7 | const ObjectId = mongoose.Types.ObjectId; 8 | 9 | const toCursorHash = string => Buffer.from(string).toString('base64'); 10 | 11 | const fromCursorHash = string => 12 | Buffer.from(string, 'base64').toString('ascii'); 13 | 14 | export default { 15 | Query: { 16 | notifications: combineResolvers( 17 | isAuthenticated, 18 | async (parent, { cursor, limit = 100 }, { models, me }) => { 19 | const cursorOptions = cursor 20 | ? { 21 | createdAt: { 22 | $lt: fromCursorHash(cursor), 23 | }, 24 | ownerId: ObjectId(me.id), 25 | } 26 | : { ownerId: ObjectId(me.id) }; 27 | 28 | const notifications = await models.Notification.find( 29 | cursorOptions, 30 | null, 31 | { 32 | sort: { createdAt: -1 }, 33 | limit: limit + 1, 34 | }, 35 | ); 36 | 37 | // console.log(notifications); 38 | 39 | const hasNextPage = notifications.length > limit; 40 | const edges = hasNextPage 41 | ? notifications.slice(0, -1) 42 | : notifications; //-1 exclude zadnji 43 | 44 | //mark as seen 45 | const notificationIds = edges.map(n => n.id); 46 | await models.Notification.updateMany( 47 | { _id: { $in: notificationIds } }, 48 | { $set: { isSeen: true } }, 49 | ); 50 | 51 | const unseenNotificationsCount = await models.Notification.find( 52 | { 53 | ownerId: me.id, 54 | isSeen: false, 55 | }, 56 | ).countDocuments(); 57 | 58 | await pubsub.publish(EVENTS.NOTIFICATION.NOT_SEEN_UPDATED, { 59 | notSeenUpdated: unseenNotificationsCount, 60 | ownerId: ObjectId(me.id), 61 | }); 62 | 63 | return { 64 | edges, 65 | pageInfo: { 66 | hasNextPage, 67 | endCursor: edges[edges.length - 1] 68 | ? toCursorHash( 69 | edges[edges.length - 1].createdAt.toString(), 70 | ) 71 | : '', 72 | }, 73 | }; 74 | }, 75 | ), 76 | notSeenNotificationsCount: combineResolvers( 77 | isAuthenticated, 78 | async (parent, args, { models, me }) => { 79 | const count = await models.Notification.find({ 80 | ownerId: me.id, 81 | isSeen: false, 82 | }).countDocuments(); 83 | return count; 84 | }, 85 | ), 86 | }, 87 | 88 | Mutation: {}, 89 | 90 | Notification: { 91 | user: async (notification, args, { models, me }) => { 92 | return await models.User.findById(notification.userId); 93 | }, 94 | }, 95 | 96 | Subscription: { 97 | notificationCreated: { 98 | subscribe: withFilter( 99 | () => pubsub.asyncIterator(EVENTS.NOTIFICATION.CREATED), 100 | async (payload, args, { me, models }) => { 101 | //me usera koji radi subskripciju, me usera koji radi like mutaciju je u messages resolveru 102 | const ownerId = 103 | payload?.notificationCreated?.notification?.ownerId; 104 | const owner = await models.User.findById(ownerId); 105 | 106 | //if message owner === loggedin user 107 | const condition = owner.username === me.username; 108 | return condition; 109 | }, 110 | ), 111 | }, 112 | notSeenUpdated: { 113 | subscribe: withFilter( 114 | () => 115 | pubsub.asyncIterator(EVENTS.NOTIFICATION.NOT_SEEN_UPDATED), 116 | async (payload, args, { me }) => { 117 | // username mi ne treba, sve imam na serveru, notification.ownerid===me.username 118 | // console.log('payload', payload, me); //pogresan me kroz ws opet 119 | const owner = await models.User.findById(payload?.ownerId); 120 | 121 | const condition = owner.username === me.username; 122 | return condition; 123 | }, 124 | ), 125 | }, 126 | }, 127 | }; 128 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ApolloProvider } from '@apollo/react-hooks'; 4 | import { ApolloClient } from 'apollo-client'; 5 | import { getMainDefinition } from 'apollo-utilities'; 6 | import { ApolloLink, split } from 'apollo-link'; 7 | // import { HttpLink } from 'apollo-link-http'; 8 | import { createUploadLink } from 'apollo-upload-client'; 9 | import { WebSocketLink } from 'apollo-link-ws'; 10 | import { onError } from 'apollo-link-error'; 11 | import { InMemoryCache } from 'apollo-cache-inmemory'; 12 | import moment from 'moment'; 13 | import MessageTypes from 'subscriptions-transport-ws/dist/message-types'; 14 | 15 | import App from './App'; 16 | import { signOut } from './components/SignOutButton/SignOutButton'; 17 | import typeDefs from './graphql/schema'; 18 | import resolvers from './graphql/resolvers'; 19 | import { customFetch } from './utils/customFetch'; 20 | 21 | const httpLink = createUploadLink({ 22 | uri: 'http://localhost:8000/graphql', 23 | fetch: customFetch, 24 | }); 25 | 26 | // const httpLink = new HttpLink({ 27 | // uri: 'http://localhost:8000/graphql', 28 | // }); 29 | 30 | const wsLink = new WebSocketLink({ 31 | uri: `ws://localhost:8000/graphql`, 32 | options: { 33 | reconnect: true, 34 | lazy: true, 35 | inactivityTimeout: 1000, 36 | // connectionParams: () => { 37 | // const token = localStorage.getItem('token'); 38 | // console.log('token ws', token); 39 | // return { 40 | // authToken: token ? token : '', 41 | // }; 42 | // }, 43 | connectionCallback: err => { 44 | if (err) { 45 | console.log('Error Connecting to Subscriptions Server', err); 46 | } 47 | }, 48 | }, 49 | }); 50 | 51 | // https://github.com/apollographql/apollo-link/issues/197 52 | const subscriptionMiddleware = { 53 | applyMiddleware: (options, next) => { 54 | options.authToken = localStorage.getItem('token'); 55 | next(); 56 | }, 57 | }; 58 | wsLink.subscriptionClient.use([subscriptionMiddleware]); 59 | 60 | const terminatingLink = split( 61 | ({ query }) => { 62 | const { kind, operation } = getMainDefinition(query); 63 | return ( 64 | kind === 'OperationDefinition' && operation === 'subscription' 65 | ); 66 | }, 67 | wsLink, 68 | httpLink, 69 | ); 70 | 71 | const authLink = new ApolloLink((operation, forward) => { 72 | operation.setContext(({ headers = {} }) => { 73 | const token = localStorage.getItem('token'); 74 | // console.log('token http', token); 75 | 76 | if (token) { 77 | headers = { ...headers, 'x-token': token }; 78 | } 79 | 80 | return { headers }; 81 | }); 82 | 83 | return forward(operation); 84 | }); 85 | 86 | const errorLink = onError(({ graphQLErrors, networkError }) => { 87 | if (graphQLErrors) { 88 | graphQLErrors.forEach(({ message, locations, path }) => { 89 | console.log('My GraphQL error', message); 90 | signOut(client); 91 | 92 | if (message === 'UNAUTHENTICATED') { 93 | signOut(client); 94 | } 95 | }); 96 | } 97 | 98 | if (networkError) { 99 | console.log('Network error', networkError); 100 | 101 | if (networkError.statusCode === 401) { 102 | signOut(client); 103 | } 104 | } 105 | }); 106 | 107 | const link = ApolloLink.from([authLink, errorLink, terminatingLink]); 108 | 109 | const cache = new InMemoryCache(); 110 | 111 | const client = new ApolloClient({ 112 | link, 113 | cache, 114 | resolvers, 115 | typeDefs, 116 | dataIdFromObject: o => o.id, 117 | }); 118 | 119 | const data = { 120 | autoplay: { 121 | __typename: 'Autoplay', 122 | direction: 'none', 123 | createdAt: moment().toISOString(), 124 | duration: 5, 125 | }, 126 | theme: { 127 | __typename: 'Theme', 128 | type: 'light', 129 | color: 'orange', 130 | }, 131 | refetchFollowers: { 132 | __typename: 'Int', 133 | signal: 0, 134 | }, 135 | messagesVariables: { 136 | __typename: 'MessagesVariables', 137 | username: null, 138 | cursor: null, 139 | limit: 2, 140 | }, 141 | }; 142 | 143 | cache.writeData({ data }); 144 | client.onResetStore(() => cache.writeData({ data })); 145 | 146 | ReactDOM.render( 147 | 148 | 149 | , 150 | document.getElementById('root'), 151 | ); 152 | -------------------------------------------------------------------------------- /client/src/pages/SignIn.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import { useMutation } from '@apollo/react-hooks'; 4 | 5 | import Avatar from '@material-ui/core/Avatar'; 6 | import Button from '@material-ui/core/Button'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import { default as MuiLink } from '@material-ui/core/Link'; 9 | import Grid from '@material-ui/core/Grid'; 10 | import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import { makeStyles } from '@material-ui/core/styles'; 13 | import Container from '@material-ui/core/Container'; 14 | 15 | import { SignUpLink } from './SignUp'; 16 | import * as routes from '../constants/routes'; 17 | import ErrorMessage from '../components/Error/Error'; 18 | 19 | import { SIGN_IN } from '../graphql/mutations'; 20 | 21 | const SignIn = ({ history, refetch }) => ( 22 | 23 | ); 24 | 25 | const useStyles = makeStyles(theme => ({ 26 | paper: { 27 | marginTop: theme.spacing(8), 28 | display: 'flex', 29 | flexDirection: 'column', 30 | alignItems: 'center', 31 | }, 32 | avatar: { 33 | margin: theme.spacing(1), 34 | backgroundColor: theme.palette.secondary.main, 35 | }, 36 | form: { 37 | width: '100%', 38 | marginTop: theme.spacing(1), 39 | }, 40 | submit: { 41 | margin: theme.spacing(3, 0, 2), 42 | }, 43 | })); 44 | 45 | const SignInForm = ({ history, refetch }) => { 46 | const [login, setLogin] = useState(''); 47 | const [password, setPassword] = useState(''); 48 | 49 | const [signIn, { loading, error }] = useMutation(SIGN_IN); 50 | 51 | const onChange = event => { 52 | const { name, value } = event.target; 53 | if (name === 'login') setLogin(value); 54 | if (name === 'password') setPassword(value); 55 | }; 56 | 57 | const onSubmit = async event => { 58 | event.preventDefault(); 59 | localStorage.removeItem('token'); 60 | 61 | const { data } = await signIn({ variables: { login, password } }); 62 | 63 | setLogin(''); 64 | setPassword(''); 65 | localStorage.setItem('token', data.signIn.token); 66 | await refetch(); 67 | history.push(routes.HOME); 68 | }; 69 | 70 | const isInvalid = 71 | !(password === '' && login === '') && 72 | (password === '' || login === ''); 73 | const classes = useStyles(); 74 | return ( 75 | 76 |
77 | 78 | 79 | 80 | 81 | Sign in 82 | 83 |
onSubmit(event)} 85 | className={classes.form} 86 | noValidate 87 | > 88 | 98 | 109 | {error && } 110 | 120 | 121 | 122 | 123 | Don't have an account?{' '} 124 | 125 | Sign Up 126 | 127 | 128 | 129 | 130 |
131 |
132 | ); 133 | }; 134 | 135 | export default withRouter(SignIn); 136 | 137 | export { SignInForm }; 138 | -------------------------------------------------------------------------------- /server/src/utils/seed.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import faker from 'faker'; 3 | import models from '../models'; 4 | // import moment from 'moment'; 5 | const ObjectId = mongoose.Types.ObjectId; 6 | 7 | export const createUsersWithMessages = async date => { 8 | console.log('seeding automated...'); 9 | 10 | const seed = new models.Seed({}); 11 | seed.save(); 12 | 13 | // 10 users 14 | const usersPromises = [...Array(10).keys()].map((index, i) => { 15 | const avatar1 = new models.File({ 16 | path: 'avatar.jpg', 17 | mimetype: 'image/jpg', 18 | filename: 'avatar.jpg', 19 | }); 20 | 21 | const cover1 = new models.File({ 22 | path: 'cover.jpg', 23 | mimetype: 'image/jpg', 24 | filename: 'cover.jpg', 25 | }); 26 | 27 | const user1 = new models.User({ 28 | username: `user${index}`, 29 | email: `email${index}@email.com`, 30 | password: '123456789', 31 | name: faker.name.findName(), 32 | bio: faker.lorem.sentences(3), 33 | avatarId: avatar1.id, 34 | coverId: cover1.id, 35 | }); 36 | 37 | if (index === 0) { 38 | user1.role = 'ADMIN'; 39 | } 40 | 41 | return { avatar1, cover1, user1 }; 42 | }); 43 | 44 | await Promise.all( 45 | usersPromises.map(async user => { 46 | await Promise.all([ 47 | user.avatar1.save(), 48 | user.cover1.save(), 49 | user.user1.save(), 50 | ]); 51 | }), 52 | ); 53 | 54 | const users = await models.User.find(); 55 | const followersIdsArr = users.map(user => user.id).slice(1); 56 | 57 | // all users follow user0 58 | await models.User.updateMany( 59 | { _id: { $ne: users[0].id } }, 60 | { $push: { followingIds: users[0].id } }, 61 | ); 62 | await models.User.updateOne( 63 | { _id: users[0].id }, 64 | { $push: { followersIds: followersIdsArr } }, 65 | ); 66 | 67 | // 30 messages, every user 3 messages 68 | const messagesPromises = [...Array(30).keys()].map((index, i) => { 69 | const audio1 = new models.File({ 70 | path: 'test.mp3', 71 | mimetype: 'audio/mpeg', 72 | filename: 'test.mp3', 73 | }); 74 | const userId = users[index % 10]._id; 75 | const message1 = new models.Message({ 76 | fileId: audio1.id, 77 | userId: userId, 78 | createdAt: date.setSeconds(date.getSeconds() - index), 79 | }); 80 | return { audio1, message1 }; 81 | }); 82 | 83 | await Promise.all( 84 | messagesPromises.map(async message => { 85 | await Promise.all([ 86 | message.audio1.save(), 87 | message.message1.save(), 88 | ]); 89 | }), 90 | ); 91 | 92 | // like one message from each user and create notification 93 | // get one message from each user 94 | //const messages = await models.Message.find({ distinct: 'userId' }); 95 | 96 | const messages = await models.Message.aggregate([ 97 | { 98 | $group: { 99 | _id: '$userId', 100 | docs: { 101 | $first: { 102 | _id: '$_id', 103 | userId: '$userId', 104 | likesIds: '$likesIds', 105 | }, 106 | }, 107 | }, 108 | }, 109 | ]); 110 | 111 | //console.log(messages.length); 112 | 113 | messages.map(async m => { 114 | //console.log(m.docs); 115 | 116 | await models.Message.updateOne( 117 | { _id: m.docs._id }, 118 | { $push: { likesIds: m.docs.userId } }, 119 | ); 120 | 121 | const notificationsLikes = await models.Notification.create({ 122 | action: 'like', 123 | ownerId: m.docs.userId, 124 | messageId: m.docs._id, 125 | userId: m.docs.userId, 126 | }); 127 | }); 128 | // const notifications = await models.Notification.find(); 129 | // console.log(notifications); 130 | 131 | /* 132 | //repost one message 133 | messages.slice(0, 1).map(async m => { 134 | const tomorrow = new Date(); 135 | tomorrow.setDate(new Date().getDate() + 1); 136 | 137 | const originalMessage = await models.Message.findById(m.docs._id); 138 | 139 | const repostedMessage = await models.Message.create({ 140 | fileId: originalMessage.fileId, 141 | userId: originalMessage.userId, 142 | isReposted: true, 143 | repost: { 144 | reposterId: originalMessage.userId, 145 | originalMessageId: originalMessage.id, 146 | }, 147 | }); 148 | }); 149 | */ 150 | }; // 151 | -------------------------------------------------------------------------------- /client/src/components/Autoplay/Autoplay.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import moment from 'moment'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | import ListSubheader from '@material-ui/core/ListSubheader'; 10 | import Switch from '@material-ui/core/Switch'; 11 | import VolumeUpIcon from '@material-ui/icons/VolumeUp'; 12 | import Slider from '@material-ui/core/Slider'; 13 | import { Typography } from '@material-ui/core'; 14 | import { useMutation } from '@apollo/react-hooks'; 15 | import { UPDATE_AUTOPLAY } from '../../graphql/mutations'; 16 | 17 | const useStyles = makeStyles(theme => ({ 18 | root: { 19 | width: '100%', 20 | // maxWidth: 360, 21 | backgroundColor: theme.palette.background.paper, 22 | }, 23 | subHeader: { 24 | paddingLeft: theme.spacing(2), 25 | paddingRight: theme.spacing(2), 26 | paddingTop: theme.spacing(1), 27 | paddingBottom: theme.spacing(1), 28 | zIndex: 0, 29 | }, 30 | title: { 31 | fontWeight: 500, 32 | }, 33 | slider: { 34 | flexDirection: 'column', 35 | alignItems: 'flex-start', 36 | }, 37 | })); 38 | 39 | const marks = [ 40 | { 41 | value: 5, 42 | label: '5s', 43 | }, 44 | { 45 | value: 10, 46 | label: '10s', 47 | }, 48 | { 49 | value: 15, 50 | label: '15s', 51 | }, 52 | { 53 | value: 20, 54 | label: '20s', 55 | }, 56 | ]; 57 | 58 | const Autoplay = ({ setMainAutoplay }) => { 59 | const classes = useStyles(); 60 | const [autoplay, setAutoplay] = React.useState('none'); 61 | const [sliderValue, setSliderValue] = React.useState(5); 62 | const [updateAutoplay] = useMutation(UPDATE_AUTOPLAY, { 63 | variables: { 64 | direction: autoplay, 65 | createdAt: moment().toISOString(), 66 | duration: sliderValue, 67 | }, 68 | }); 69 | 70 | useEffect(() => { 71 | updateAutoplay(); 72 | }, [autoplay, sliderValue]); 73 | 74 | const toggleAutoplay = value => { 75 | if (autoplay === 'none') { 76 | setAutoplay(value); 77 | } else if (value === autoplay) { 78 | setAutoplay('none'); 79 | } else if (value === 'existing') { 80 | if (autoplay === 'existing') { 81 | setAutoplay('incoming'); 82 | } else { 83 | setAutoplay('existing'); 84 | } 85 | } else { 86 | if (autoplay === 'incoming') { 87 | setAutoplay('existing'); 88 | } else { 89 | setAutoplay('incoming'); 90 | } 91 | } 92 | }; 93 | 94 | const handleSliderChange = (event, newValue) => { 95 | setSliderValue(newValue); 96 | }; 97 | 98 | return ( 99 | 102 | 103 | Autoplay: {autoplay} 104 | 105 | 106 | } 107 | className={classes.root} 108 | > 109 | 110 | 111 | 112 | 113 | 114 | 115 | toggleAutoplay('existing')} 118 | checked={autoplay === 'existing'} 119 | color="primary" 120 | /> 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | toggleAutoplay('incoming')} 132 | checked={autoplay === 'incoming'} 133 | color="primary" 134 | /> 135 | 136 | 137 | 138 | 139 | Play first {sliderValue} seconds 140 | 141 | 149 | 150 | 151 | ); 152 | }; 153 | 154 | export default Autoplay; 155 | -------------------------------------------------------------------------------- /client/src/graphql/queries.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_ALL_MESSAGES_WITH_USERS = gql` 4 | query { 5 | messages(order: "DESC") @connection(key: "MessagesConnection") { 6 | edges { 7 | id 8 | createdAt 9 | likesCount 10 | isLiked 11 | repostsCount 12 | isReposted 13 | isRepostedByMe 14 | repost { 15 | reposter { 16 | id 17 | username 18 | name 19 | } 20 | originalMessage { 21 | id 22 | createdAt 23 | } 24 | } 25 | user { 26 | id 27 | username 28 | name 29 | avatar { 30 | id 31 | path 32 | } 33 | } 34 | file { 35 | id 36 | path 37 | } 38 | } 39 | pageInfo { 40 | hasNextPage 41 | } 42 | } 43 | } 44 | `; 45 | 46 | export const GET_PAGINATED_MESSAGES_WITH_USERS = gql` 47 | query($cursor: String, $limit: Int!, $username: String) { 48 | messages(cursor: $cursor, limit: $limit, username: $username) 49 | @connection(key: "MessagesConnection") { 50 | edges { 51 | id 52 | createdAt 53 | likesCount 54 | isLiked 55 | repostsCount 56 | isReposted 57 | isRepostedByMe 58 | repost { 59 | reposter { 60 | id 61 | username 62 | name 63 | } 64 | originalMessage { 65 | id 66 | createdAt 67 | } 68 | } 69 | user { 70 | id 71 | username 72 | name 73 | avatar { 74 | id 75 | path 76 | } 77 | } 78 | file { 79 | id 80 | path 81 | } 82 | } 83 | pageInfo { 84 | hasNextPage 85 | endCursor 86 | } 87 | } 88 | } 89 | `; 90 | 91 | export const GET_PAGINATED_NOTIFICATIONS = gql` 92 | query($cursor: String, $limit: Int!) { 93 | notifications(cursor: $cursor, limit: $limit) 94 | @connection(key: "NotificationsConnection") { 95 | edges { 96 | id 97 | createdAt 98 | action 99 | user { 100 | id 101 | username 102 | name 103 | avatar { 104 | id 105 | path 106 | } 107 | } 108 | } 109 | pageInfo { 110 | hasNextPage 111 | endCursor 112 | } 113 | } 114 | } 115 | `; 116 | 117 | export const GET_FRIENDS = gql` 118 | query( 119 | $username: String! 120 | $isFollowers: Boolean 121 | $isFollowing: Boolean 122 | $limit: Int 123 | ) { 124 | friends( 125 | username: $username 126 | isFollowers: $isFollowers 127 | isFollowing: $isFollowing 128 | limit: $limit 129 | ) { 130 | id 131 | username 132 | name 133 | bio 134 | isFollowsMe 135 | isFollowHim 136 | avatar { 137 | id 138 | path 139 | } 140 | cover { 141 | id 142 | path 143 | } 144 | } 145 | } 146 | `; 147 | 148 | export const GET_WHO_TO_FOLLOW = gql` 149 | query($limit: Int) { 150 | whoToFollow(limit: $limit) { 151 | id 152 | username 153 | name 154 | isFollowsMe 155 | isFollowHim 156 | avatar { 157 | id 158 | path 159 | } 160 | } 161 | } 162 | `; 163 | 164 | export const GET_NOT_SEEN_NOTIFICATIONS_COUNT = gql` 165 | query { 166 | notSeenNotificationsCount 167 | } 168 | `; 169 | 170 | export const GET_USER = gql` 171 | query($username: String!) { 172 | user(username: $username) { 173 | id 174 | username 175 | name 176 | bio 177 | followersCount 178 | followingCount 179 | messagesCount 180 | isFollowsMe 181 | isFollowHim 182 | avatar { 183 | id 184 | path 185 | } 186 | cover { 187 | id 188 | path 189 | } 190 | } 191 | } 192 | `; 193 | 194 | export const GET_AUTOPLAY = gql` 195 | query { 196 | autoplay @client { 197 | direction 198 | createdAt 199 | duration 200 | } 201 | } 202 | `; 203 | 204 | export const GET_THEME = gql` 205 | query { 206 | theme @client(always: true) { 207 | type 208 | color 209 | } 210 | } 211 | `; 212 | 213 | export const GET_REFETCH_FOLLOWERS = gql` 214 | query { 215 | refetchFollowers @client { 216 | signal 217 | } 218 | } 219 | `; 220 | 221 | export const GET_MESSAGES_VARIABLES = gql` 222 | query { 223 | messagesVariables @client { 224 | username 225 | cursor 226 | limit 227 | } 228 | } 229 | `; 230 | -------------------------------------------------------------------------------- /client/src/components/UserCard/UserCard.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { useQuery, useMutation } from '@apollo/react-hooks'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Card from '@material-ui/core/Card'; 7 | import CardActionArea from '@material-ui/core/CardActionArea'; 8 | import CardActions from '@material-ui/core/CardActions'; 9 | import CardContent from '@material-ui/core/CardContent'; 10 | import CardMedia from '@material-ui/core/CardMedia'; 11 | import Button from '@material-ui/core/Button'; 12 | import Typography from '@material-ui/core/Typography'; 13 | import Avatar from '@material-ui/core/Avatar'; 14 | import CircularProgress from '@material-ui/core/CircularProgress'; 15 | import Link from '@material-ui/core/Link'; 16 | 17 | import { 18 | GET_USER, 19 | GET_REFETCH_FOLLOWERS, 20 | } from '../../graphql/queries'; 21 | import { 22 | FOLLOW_USER, 23 | UNFOLLOW_USER, 24 | SET_REFETCH_FOLLOWERS, 25 | } from '../../graphql/mutations'; 26 | 27 | import { UPLOADS_IMAGES_FOLDER } from '../../constants/paths'; 28 | 29 | const useStyles = makeStyles(theme => ({ 30 | root: { 31 | maxWidth: 255, 32 | textAlign: 'left', 33 | zIndex: 5, 34 | }, 35 | media: { 36 | height: 110, 37 | position: 'relative', 38 | paddingTop: '11%', 39 | }, 40 | avatar: { 41 | width: 112, 42 | height: 112, 43 | left: 12, 44 | border: '2px solid #ffffff', 45 | position: 'absolute', 46 | }, 47 | follow: { 48 | display: 'flex', 49 | alignItems: 'center', 50 | justifyContent: 'flex-end', 51 | }, 52 | nameDiv: { 53 | marginBottom: theme.spacing(1), 54 | }, 55 | name: { 56 | fontWeight: 'bold', 57 | }, 58 | followers: { 59 | display: 'flex', 60 | alignItems: 'center', 61 | }, 62 | followersNumber: { 63 | fontWeight: 'bold', 64 | marginRight: theme.spacing(1), 65 | }, 66 | })); 67 | 68 | const UserCard = ({ session, username, ...rest }) => { 69 | const classes = useStyles(); 70 | 71 | const { data, error, loading, refetch } = useQuery(GET_USER, { 72 | variables: { username }, 73 | }); 74 | 75 | const { 76 | data: { 77 | refetchFollowers: { signal }, 78 | }, 79 | } = useQuery(GET_REFETCH_FOLLOWERS); 80 | 81 | useEffect(() => { 82 | refetch(); 83 | }, [signal]); 84 | 85 | const [followUser] = useMutation(FOLLOW_USER); 86 | const [unfollowUser] = useMutation(UNFOLLOW_USER); 87 | const [setRefetchFollowers] = useMutation(SET_REFETCH_FOLLOWERS); 88 | 89 | const handleFollow = async user => { 90 | await followUser({ variables: { username: user.username } }); 91 | setRefetchFollowers(); 92 | }; 93 | const handleUnfollow = async user => { 94 | await unfollowUser({ variables: { username: user.username } }); 95 | setRefetchFollowers(); 96 | }; 97 | 98 | if (loading) return ; 99 | const { user } = data; 100 | 101 | return ( 102 | 103 | 107 | 111 | 112 | 113 |
114 | {user.isFollowHim ? ( 115 | 123 | ) : ( 124 | 133 | )} 134 |
135 |
136 | 141 | 142 | {user.name} 143 | 144 | 145 | 146 | @{user.username} 147 | 148 |
149 | 154 | {`${user.bio.substring(0, 54)}...`} 155 | 156 |
157 | 161 | {user.followersCount} 162 | 163 | 168 | Followers 169 | 170 | 174 | {user.followingCount} 175 | 176 | 177 | Following 178 | 179 |
180 |
181 |
182 | ); 183 | }; 184 | 185 | export default UserCard; 186 | -------------------------------------------------------------------------------- /client/src/pages/SignUp.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import { useMutation } from '@apollo/react-hooks'; 4 | 5 | import Avatar from '@material-ui/core/Avatar'; 6 | import Button from '@material-ui/core/Button'; 7 | import CssBaseline from '@material-ui/core/CssBaseline'; 8 | import TextField from '@material-ui/core/TextField'; 9 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 10 | import Checkbox from '@material-ui/core/Checkbox'; 11 | import { default as MuiLink } from '@material-ui/core/Link'; 12 | import Grid from '@material-ui/core/Grid'; 13 | import Box from '@material-ui/core/Box'; 14 | import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; 15 | import Typography from '@material-ui/core/Typography'; 16 | import { makeStyles } from '@material-ui/core/styles'; 17 | import Container from '@material-ui/core/Container'; 18 | 19 | import * as routes from '../constants/routes'; 20 | import ErrorMessage from '../components/Error/Error'; 21 | 22 | import { SIGN_UP } from '../graphql/mutations'; 23 | 24 | const SignUp = ({ history, refetch }) => ( 25 | 26 | ); 27 | 28 | const useStyles = makeStyles(theme => ({ 29 | paper: { 30 | marginTop: theme.spacing(2), 31 | display: 'flex', 32 | flexDirection: 'column', 33 | alignItems: 'center', 34 | }, 35 | avatar: { 36 | margin: theme.spacing(1), 37 | backgroundColor: theme.palette.secondary.main, 38 | }, 39 | form: { 40 | width: '100%', // Fix IE 11 issue. 41 | marginTop: theme.spacing(3), 42 | }, 43 | submit: { 44 | margin: theme.spacing(3, 0, 2), 45 | }, 46 | })); 47 | 48 | const SignUpForm = ({ history, refetch }) => { 49 | const [username, setUsername] = useState(''); 50 | const [email, setEmail] = useState(''); 51 | const [password, setPassword] = useState(''); 52 | const [passwordConfirmation, setPasswordConfirmation] = useState( 53 | '', 54 | ); 55 | 56 | const [signUp, { loading, error }] = useMutation(SIGN_UP); 57 | 58 | const onChange = event => { 59 | const { name, value } = event.target; 60 | if (name === 'username') setUsername(value); 61 | if (name === 'email') setEmail(value); 62 | if (name === 'password') setPassword(value); 63 | if (name === 'passwordConfirmation') 64 | setPasswordConfirmation(value); 65 | }; 66 | 67 | const onSubmit = async event => { 68 | event.preventDefault(); 69 | 70 | const { data } = await signUp({ 71 | variables: { username, email, password }, 72 | }); 73 | 74 | console.log(data); 75 | 76 | setUsername(''); 77 | setEmail(''); 78 | setPassword(''); 79 | setPasswordConfirmation(''); 80 | 81 | localStorage.setItem('token', data.signUp.token); 82 | await refetch(); 83 | history.push(routes.HOME); 84 | }; 85 | 86 | const isInvalid = 87 | password !== passwordConfirmation || 88 | password === '' || 89 | email === '' || 90 | username === ''; 91 | const classes = useStyles(); 92 | return ( 93 | 94 |
95 | 96 | 97 | 98 | 99 | Sign up 100 | 101 |
onSubmit(event)} 104 | noValidate 105 | > 106 | 107 | 108 | 115 | 116 | 117 | 124 | 125 | 126 | 134 | 135 | 136 | 144 | 145 | 146 | {error && } 147 | 157 | 158 | 159 | Already have an account?{' '} 160 | 161 | Sign in 162 | 163 | 164 | 165 | 166 |
167 |
168 | ); 169 | }; 170 | 171 | const SignUpLink = () => ( 172 |

173 | Don't have an account? Sign Up 174 |

175 | ); 176 | 177 | export default withRouter(SignUp); 178 | 179 | export { SignUpForm, SignUpLink }; 180 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import cors from 'cors'; 3 | import morgan from 'morgan'; 4 | import http from 'http'; 5 | import jwt from 'jsonwebtoken'; 6 | import DataLoader from 'dataloader'; 7 | import express from 'express'; 8 | import { 9 | ApolloServer, 10 | AuthenticationError, 11 | } from 'apollo-server-express'; 12 | 13 | import { existsSync, mkdirSync } from 'fs'; 14 | import path from 'path'; 15 | import moment from 'moment'; 16 | 17 | import schema from './schema'; 18 | import resolvers from './resolvers'; 19 | import models, { connectDb } from './models'; 20 | import loaders from './loaders'; 21 | import { createUsersWithMessages } from './utils/seed'; 22 | import { deleteFile } from './utils/upload'; 23 | 24 | const app = express(); 25 | 26 | app.use(cors()); 27 | app.use(morgan('dev')); 28 | 29 | existsSync(path.join(__dirname, '../uploads')) || 30 | mkdirSync(path.join(__dirname, '../uploads')); 31 | app.use( 32 | '/uploads', 33 | express.static(path.join(__dirname, '../uploads')), 34 | ); 35 | 36 | /* 37 | app.use(async (req, res, next) => { 38 | // console.log( 39 | // 'My Time: ', 40 | // moment().format('dddd, MMMM Do YYYY, k:mm:ss'), 41 | // ); 42 | 43 | const seed = await models.Seed.findOne({ 44 | seed: 'seed', 45 | }); 46 | 47 | console.log('Database created: ', moment(seed.createdAt).fromNow()); 48 | 49 | const databaseOldInMinutes = moment().diff( 50 | moment(seed.createdAt), 51 | 'minutes', 52 | ); 53 | 54 | if (databaseOldInMinutes > process.env.RESEED_DATABASE_MINUTES) { 55 | reseedDatabase(); 56 | console.log('Older than 5min, reseeding...'); 57 | } 58 | 59 | next(); 60 | }); 61 | */ 62 | 63 | // vadi usera iz tokena, a ne iz baze 64 | // i mece ga u context 65 | const getMe = async req => { 66 | const token = req.headers['x-token']; 67 | 68 | if (token) { 69 | try { 70 | return await jwt.verify(token, process.env.SECRET); 71 | } catch (e) { 72 | throw new AuthenticationError( 73 | 'Your session expired. Sign in again.', 74 | ); 75 | } 76 | } 77 | }; 78 | 79 | const server = new ApolloServer({ 80 | introspection: true, 81 | typeDefs: schema, 82 | resolvers, 83 | subscriptions: { 84 | keepAlive: 30000, 85 | // onConnect: async (connectionParams, webSocket, context) => { 86 | // const token = connectionParams.authToken; 87 | // const me = await jwt.verify(token, process.env.SECRET); 88 | // return { me }; 89 | // }, 90 | }, 91 | formatError: error => { 92 | // remove the internal sequelize error message 93 | // leave only the important validation error 94 | const message = error.message 95 | .replace('SequelizeValidationError: ', '') // string replace 96 | .replace('Validation error: ', ''); 97 | 98 | return { 99 | ...error, 100 | message, 101 | }; 102 | }, 103 | context: async ({ req, connection, payload }) => { 104 | // subskribcije 105 | if (connection) { 106 | // const me = connection.context.me; 107 | const token = payload.authToken; 108 | //console.log(payload); 109 | const me = token 110 | ? await jwt.verify(token, process.env.SECRET) 111 | : null; 112 | console.log('ws me ', me?.username, me?.id); 113 | return { 114 | me, 115 | models, 116 | loaders: { 117 | // batching, samo unique keys usera, nema ponavljanja 118 | // batching: puno malih sql upita u jedan veci i brzi mnogo 119 | // DataLoader facebookova biblioteka 120 | // cache u okviru jednog zahteva, ako van server scope onda vise kesira... 121 | // ide u context 122 | // gledas koliko upita baza izvrsava negde u mongo konzoli 123 | user: new DataLoader(keys => 124 | loaders.user.batchUsers(keys, models), 125 | ), 126 | file: new DataLoader(keys => 127 | loaders.file.batchFiles(keys, models), 128 | ), 129 | }, 130 | }; 131 | } 132 | 133 | // http, mutacije i queries 134 | if (req) { 135 | const me = await getMe(req); 136 | console.log('http me ', me?.username, me?.id); 137 | 138 | return { 139 | models, 140 | me, 141 | secret: process.env.SECRET, 142 | loaders: { 143 | user: new DataLoader(keys => 144 | loaders.user.batchUsers(keys, models), 145 | ), 146 | file: new DataLoader(keys => 147 | loaders.file.batchFiles(keys, models), 148 | ), 149 | }, 150 | }; 151 | } 152 | }, 153 | }); 154 | 155 | server.applyMiddleware({ app, path: '/graphql' }); 156 | 157 | const httpServer = http.createServer(app); 158 | server.installSubscriptionHandlers(httpServer); // subscribtions 159 | 160 | const isTest = !!process.env.TEST_DATABASE_URL; 161 | const isProduction = process.env.NODE_ENV === 'production'; 162 | const port = process.env.PORT || 8000; 163 | 164 | // vraca promise sa konekcijom koju ne koristi 165 | connectDb().then(async connection => { 166 | if (isTest || isProduction) { 167 | await reseedDatabase(); 168 | } 169 | 170 | httpServer.listen({ port }, () => { 171 | console.log(`Apollo Server on http://localhost:${port}/graphql`); 172 | }); 173 | }); 174 | 175 | const reseedDatabase = async () => { 176 | const files = await models.File.find({ 177 | path: { $nin: ['test.mp3', 'avatar.jpg', 'cover.jpg'] }, 178 | }); 179 | // console.log(files); 180 | files.map(file => { 181 | deleteFile(file.path); 182 | }); 183 | 184 | // reset database 185 | await Promise.all([ 186 | models.User.deleteMany({}), 187 | models.Message.deleteMany({}), 188 | models.File.deleteMany({}), 189 | models.Notification.deleteMany({}), 190 | models.Seed.deleteMany({}), 191 | ]); 192 | 193 | await createUsersWithMessages(new Date()); 194 | }; 195 | -------------------------------------------------------------------------------- /client/src/components/Messages/Messages.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback, Fragment } from 'react'; 2 | import { useQuery, useMutation } from '@apollo/react-hooks'; 3 | import moment from 'moment'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import Button from '@material-ui/core/Button'; 8 | 9 | import MessagePlayer from '../MessagePlayer/MessagePlayer'; 10 | import Loading from '../Loading/Loading'; 11 | 12 | import { 13 | GET_PAGINATED_MESSAGES_WITH_USERS, 14 | GET_AUTOPLAY, 15 | GET_MESSAGES_VARIABLES, 16 | } from '../../graphql/queries'; 17 | import { SET_MESSAGES_VARIABLES } from '../../graphql/mutations'; 18 | import { MESSAGE_CREATED } from '../../graphql/subscriptions'; 19 | 20 | const useStylesMessages = makeStyles(theme => ({ 21 | noMessages: { 22 | padding: theme.spacing(2), 23 | textAlign: 'center', 24 | backgroundColor: theme.palette.background.paper, 25 | }, 26 | })); 27 | 28 | const Messages = ({ limit, username, session }) => { 29 | const { 30 | data, 31 | loading, 32 | error, 33 | refetch, 34 | fetchMore, 35 | subscribeToMore, 36 | } = useQuery(GET_PAGINATED_MESSAGES_WITH_USERS, { 37 | variables: { limit, username }, 38 | }); 39 | const classes = useStylesMessages(); 40 | const [setMessagesVariables] = useMutation(SET_MESSAGES_VARIABLES); 41 | 42 | useEffect(() => { 43 | refetch(); 44 | const username = username || null; 45 | setMessagesVariables({ 46 | variables: { username: username, limit, cursor: null }, 47 | }); 48 | }, [username, limit]); 49 | 50 | if (loading) { 51 | return ; 52 | } 53 | // logujes error ako ne radi 54 | // console.log(data, error, username); 55 | 56 | if (!data || data.messages.edges.length === 0) { 57 | return ( 58 |
59 | There are no messages yet ... 60 |
61 | ); 62 | } 63 | 64 | const { messages } = data; 65 | 66 | //console.log(messages); 67 | const { edges, pageInfo } = messages; 68 | 69 | return ( 70 | 71 | 77 | 78 | {pageInfo.hasNextPage && ( 79 | 80 | 81 | 87 | More 88 | 89 | 90 | 91 | )} 92 | 93 | ); 94 | }; 95 | 96 | const MoreMessagesButton = ({ 97 | limit, 98 | pageInfo, 99 | fetchMore, 100 | children, 101 | username, 102 | }) => { 103 | const { 104 | data: { messagesVariables }, 105 | } = useQuery(GET_MESSAGES_VARIABLES); 106 | 107 | const [setMessagesVariables] = useMutation(SET_MESSAGES_VARIABLES); 108 | 109 | const moreMessagesHandler = () => { 110 | setMessagesVariables({ 111 | variables: { 112 | username, 113 | limit: limit + messagesVariables.limit, 114 | cursor: null, 115 | }, 116 | }); 117 | 118 | fetchMore({ 119 | variables: { 120 | cursor: pageInfo.endCursor, 121 | limit, 122 | username, 123 | }, 124 | updateQuery: (previousResult, { fetchMoreResult }) => { 125 | if (!fetchMoreResult) { 126 | return previousResult; 127 | } 128 | 129 | return { 130 | messages: { 131 | ...fetchMoreResult.messages, 132 | edges: [ 133 | ...previousResult.messages.edges, 134 | ...fetchMoreResult.messages.edges, 135 | ], 136 | }, 137 | }; 138 | }, 139 | }); 140 | }; 141 | 142 | return ( 143 | 150 | ); 151 | }; 152 | 153 | const useStyles = makeStyles(theme => ({ 154 | root: {}, 155 | item: { flex: 1 }, 156 | })); 157 | 158 | const MessageList = ({ 159 | messages, 160 | subscribeToMore, 161 | session, 162 | username, 163 | }) => { 164 | const classes = useStyles(); 165 | const subscribeToMoreMessage = useCallback(() => { 166 | subscribeToMore({ 167 | document: MESSAGE_CREATED, 168 | variables: { username }, 169 | updateQuery: (previousResult, { subscriptionData }) => { 170 | if (!subscriptionData.data) { 171 | return previousResult; 172 | } 173 | 174 | const { messageCreated } = subscriptionData.data; 175 | 176 | const result = { 177 | ...previousResult, 178 | messages: { 179 | ...previousResult.messages, 180 | edges: [ 181 | messageCreated.message, 182 | ...previousResult.messages.edges, 183 | ], 184 | }, 185 | }; 186 | return result; 187 | }, 188 | }); 189 | }, [subscribeToMore, username]); 190 | 191 | useEffect(() => { 192 | subscribeToMoreMessage(); 193 | }, [subscribeToMoreMessage]); 194 | 195 | const { 196 | data: { 197 | autoplay: { direction, createdAt, duration }, 198 | }, 199 | } = useQuery(GET_AUTOPLAY); 200 | 201 | const shouldPlay = message => { 202 | if (direction === 'existing') { 203 | const firstMessage = messages.find(m => 204 | moment(m.createdAt).isBefore(createdAt), 205 | ); 206 | const result = firstMessage && message.id === firstMessage.id; 207 | return result; 208 | } 209 | if (direction === 'incoming') { 210 | const firstMessage = messages.find(m => 211 | moment(m.createdAt).isAfter(createdAt), 212 | ); 213 | const result = firstMessage && message.id === firstMessage.id; 214 | return result; 215 | } 216 | }; 217 | 218 | return ( 219 | 225 | {messages.map((message, i) => { 226 | return ( 227 | 228 | 235 | 236 | ); 237 | })} 238 | 239 | ); 240 | }; 241 | 242 | export default Messages; 243 | -------------------------------------------------------------------------------- /client/src/components/WhoToFollow/WhoToFollow.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { useQuery, useMutation } from '@apollo/react-hooks'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import List from '@material-ui/core/List'; 7 | import ListItem from '@material-ui/core/ListItem'; 8 | import Divider from '@material-ui/core/Divider'; 9 | import ListItemText from '@material-ui/core/ListItemText'; 10 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 11 | import ListSubheader from '@material-ui/core/ListSubheader'; 12 | import Avatar from '@material-ui/core/Avatar'; 13 | import Typography from '@material-ui/core/Typography'; 14 | import Button from '@material-ui/core/Button'; 15 | import CircularProgress from '@material-ui/core/CircularProgress'; 16 | import Link from '@material-ui/core/Link'; 17 | import Fade from '@material-ui/core/Fade'; 18 | import Popper from '@material-ui/core/Popper'; 19 | 20 | import { 21 | GET_WHO_TO_FOLLOW, 22 | GET_REFETCH_FOLLOWERS, 23 | } from '../../graphql/queries'; 24 | import { 25 | FOLLOW_USER, 26 | UNFOLLOW_USER, 27 | SET_REFETCH_FOLLOWERS, 28 | } from '../../graphql/mutations'; 29 | import { UPLOADS_IMAGES_FOLDER } from '../../constants/paths'; 30 | 31 | import UserCard from '../UserCard/UserCard'; 32 | 33 | const useStyles = makeStyles(theme => ({ 34 | root: { 35 | width: '100%', 36 | backgroundColor: theme.palette.background.paper, 37 | }, 38 | inline: { 39 | display: 'inline', 40 | }, 41 | subHeader: { 42 | paddingLeft: theme.spacing(2), 43 | paddingRight: theme.spacing(2), 44 | paddingTop: theme.spacing(1), 45 | paddingBottom: theme.spacing(1), 46 | zIndex: 0, 47 | }, 48 | title: { 49 | fontWeight: 500, 50 | }, 51 | })); 52 | 53 | const WhoToFollow = ({ session }) => { 54 | const classes = useStyles(); 55 | 56 | const [anchorEl, setAnchorEl] = useState(null); 57 | const [popUpEntered, setPopUpEntered] = useState(false); 58 | const [nameEntered, setNameEntered] = useState(false); 59 | const [username, setUsername] = useState(''); 60 | 61 | const { data, error, loading, refetch } = useQuery( 62 | GET_WHO_TO_FOLLOW, 63 | { 64 | variables: { limit: 3 }, 65 | }, 66 | ); 67 | const { 68 | data: { 69 | refetchFollowers: { signal }, 70 | }, 71 | loading: refetchFollowersLoading, 72 | } = useQuery(GET_REFETCH_FOLLOWERS); 73 | 74 | useEffect(() => { 75 | refetch(); 76 | }, [signal, session?.me, refetch]); 77 | 78 | useEffect(() => { 79 | setTimeout(() => { 80 | if (!popUpEntered && !nameEntered) { 81 | setAnchorEl(null); 82 | setPopUpEntered(false); 83 | setNameEntered(false); 84 | } 85 | }, 300); 86 | }, [popUpEntered, nameEntered]); 87 | 88 | const [followUser] = useMutation(FOLLOW_USER); 89 | const [unfollowUser] = useMutation(UNFOLLOW_USER); 90 | const [setRefetchFollowers] = useMutation(SET_REFETCH_FOLLOWERS); 91 | 92 | //console.log(refetchFollowersData, error); 93 | 94 | if (!data || loading) return ; 95 | 96 | const { whoToFollow } = data; 97 | 98 | const handleFollow = async user => { 99 | await followUser({ variables: { username: user.username } }); 100 | setRefetchFollowers(); 101 | }; 102 | const handleUnfollow = async user => { 103 | await unfollowUser({ variables: { username: user.username } }); 104 | setRefetchFollowers(); 105 | }; 106 | const handleMouseEnter = (event, user) => { 107 | setAnchorEl(event.currentTarget); 108 | setNameEntered(true); 109 | setUsername(user.username); 110 | }; 111 | const handleMouseLeave = event => { 112 | setNameEntered(false); 113 | }; 114 | 115 | const handlePopUpMouseEnter = event => { 116 | setPopUpEntered(true); 117 | }; 118 | const handlePopUpMouseLeave = event => { 119 | setPopUpEntered(false); 120 | }; 121 | 122 | const popperOpen = Boolean(anchorEl); 123 | return ( 124 | <> 125 | 131 | {({ TransitionProps }) => ( 132 | 133 | 139 | 140 | )} 141 | 142 | 145 | 146 | Who to follow 147 | 148 | 149 | } 150 | className={classes.root} 151 | > 152 | {whoToFollow.map((user, index) => { 153 | return ( 154 | 155 | 156 | 157 | 161 | 165 | 166 | 167 | handleMouseEnter(e, user)} 174 | onMouseLeave={handleMouseLeave} 175 | > 176 | {user.name} 177 | 178 | } 179 | secondary={ 180 | 186 | {`@${user.username}`} 187 | 188 | } 189 | /> 190 | {user.isFollowHim ? ( 191 | 199 | ) : ( 200 | 208 | )} 209 | 210 | {index !== 2 && ( 211 | 212 | )} 213 | 214 | ); 215 | })} 216 | 217 | 218 | ); 219 | }; 220 | 221 | export default WhoToFollow; 222 | -------------------------------------------------------------------------------- /server/src/tests/user.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import * as api from './api'; 4 | import models, { connectDb } from '../models'; 5 | import mongoose from 'mongoose'; 6 | 7 | let db; 8 | let expectedUsers; 9 | let expectedUser; 10 | let expectedAdminUser; 11 | 12 | before(async () => { 13 | db = await connectDb('mongodb://localhost:27017/mytestdatabase'); 14 | 15 | expectedUsers = await models.User.find(); 16 | 17 | expectedUser = expectedUsers.filter( 18 | user => user.role !== 'ADMIN', 19 | )[0]; 20 | 21 | expectedAdminUser = expectedUsers.filter( 22 | user => user.role === 'ADMIN', 23 | )[0]; 24 | }); 25 | 26 | after(async () => { 27 | await db.connection.close(); 28 | }); 29 | 30 | describe('users', () => { 31 | describe('user(id: String!): User', () => { 32 | it('returns a user when user can be found', async () => { 33 | const expectedResult = { 34 | data: { 35 | user: { 36 | id: expectedUser.id, 37 | username: expectedUser.username, 38 | email: expectedUser.email, 39 | role: null, 40 | }, 41 | }, 42 | }; 43 | 44 | const result = await api.user({ id: expectedUser.id }); 45 | 46 | expect(result.data).to.eql(expectedResult); 47 | }); 48 | 49 | it('returns null when user cannot be found', async () => { 50 | const expectedResult = { 51 | data: { 52 | user: null, 53 | }, 54 | }; 55 | 56 | const result = await api.user({ 57 | id: new mongoose.Types.ObjectId(), 58 | }); 59 | 60 | expect(result.data).to.eql(expectedResult); 61 | }); 62 | }); 63 | 64 | describe('users: [User!]', () => { 65 | it('returns a list of users', async () => { 66 | const expectedResult = { 67 | data: { 68 | users: [ 69 | { 70 | id: expectedAdminUser.id, 71 | username: expectedAdminUser.username, 72 | email: expectedAdminUser.email, 73 | role: expectedAdminUser.role, 74 | }, 75 | { 76 | id: expectedUser.id, 77 | username: expectedUser.username, 78 | email: expectedUser.email, 79 | role: null, 80 | }, 81 | ], 82 | }, 83 | }; 84 | 85 | const result = await api.users(); 86 | 87 | expect(result.data).to.eql(expectedResult); 88 | }); 89 | }); 90 | 91 | describe('me: User', () => { 92 | it('returns null when no user is signed in', async () => { 93 | const expectedResult = { 94 | data: { 95 | me: null, 96 | }, 97 | }; 98 | 99 | const { data } = await api.me(); 100 | 101 | expect(data).to.eql(expectedResult); 102 | }); 103 | 104 | it('returns me when me is signed in', async () => { 105 | const expectedResult = { 106 | data: { 107 | me: { 108 | id: expectedAdminUser.id, 109 | username: expectedAdminUser.username, 110 | email: expectedAdminUser.email, 111 | }, 112 | }, 113 | }; 114 | 115 | const { 116 | data: { 117 | data: { 118 | signIn: { token }, 119 | }, 120 | }, 121 | } = await api.signIn({ 122 | login: 'rwieruch', 123 | password: 'rwieruch', 124 | }); 125 | 126 | const { data } = await api.me(token); 127 | 128 | expect(data).to.eql(expectedResult); 129 | }); 130 | }); 131 | 132 | describe('signUp, updateUser, deleteUser', () => { 133 | it('signs up a user, updates a user and deletes the user as admin', async () => { 134 | // sign up 135 | 136 | let { 137 | data: { 138 | data: { 139 | signUp: { token }, 140 | }, 141 | }, 142 | } = await api.signUp({ 143 | username: 'Mark', 144 | email: 'mark@gmule.com', 145 | password: 'asdasdasd', 146 | }); 147 | 148 | const expectedNewUser = await models.User.findByLogin( 149 | 'mark@gmule.com', 150 | ); 151 | 152 | const { 153 | data: { 154 | data: { me }, 155 | }, 156 | } = await api.me(token); 157 | 158 | expect(me).to.eql({ 159 | id: expectedNewUser.id, 160 | username: expectedNewUser.username, 161 | email: expectedNewUser.email, 162 | }); 163 | 164 | // update as user 165 | 166 | const { 167 | data: { 168 | data: { updateUser }, 169 | }, 170 | } = await api.updateUser({ username: 'Marc' }, token); 171 | 172 | expect(updateUser.username).to.eql('Marc'); 173 | 174 | // delete as admin 175 | 176 | const { 177 | data: { 178 | data: { 179 | signIn: { token: adminToken }, 180 | }, 181 | }, 182 | } = await api.signIn({ 183 | login: 'rwieruch', 184 | password: 'rwieruch', 185 | }); 186 | 187 | const { 188 | data: { 189 | data: { deleteUser }, 190 | }, 191 | } = await api.deleteUser({ id: me.id }, adminToken); 192 | 193 | expect(deleteUser).to.eql(true); 194 | }); 195 | }); 196 | 197 | describe('deleteUser(id: String!): Boolean!', () => { 198 | it('returns an error because only admins can delete a user', async () => { 199 | const { 200 | data: { 201 | data: { 202 | signIn: { token }, 203 | }, 204 | }, 205 | } = await api.signIn({ 206 | login: 'ddavids', 207 | password: 'ddavids', 208 | }); 209 | 210 | const { 211 | data: { errors }, 212 | } = await api.deleteUser({ id: expectedAdminUser.id }, token); 213 | 214 | expect(errors[0].message).to.eql('Not authorized as admin.'); 215 | }); 216 | }); 217 | 218 | describe('updateUser(username: String!): User!', () => { 219 | it('returns an error because only authenticated users can update a user', async () => { 220 | const { 221 | data: { errors }, 222 | } = await api.updateUser({ username: 'Marc' }); 223 | 224 | expect(errors[0].message).to.eql('Not authenticated as user.'); 225 | }); 226 | }); 227 | 228 | describe('signIn(login: String!, password: String!): Token!', () => { 229 | it('returns a token when a user signs in with username', async () => { 230 | const { 231 | data: { 232 | data: { 233 | signIn: { token }, 234 | }, 235 | }, 236 | } = await api.signIn({ 237 | login: 'ddavids', 238 | password: 'ddavids', 239 | }); 240 | 241 | expect(token).to.be.a('string'); 242 | }); 243 | 244 | it('returns a token when a user signs in with email', async () => { 245 | const { 246 | data: { 247 | data: { 248 | signIn: { token }, 249 | }, 250 | }, 251 | } = await api.signIn({ 252 | login: 'hello@david.com', 253 | password: 'ddavids', 254 | }); 255 | 256 | expect(token).to.be.a('string'); 257 | }); 258 | 259 | it('returns an error when a user provides a wrong password', async () => { 260 | const { 261 | data: { errors }, 262 | } = await api.signIn({ 263 | login: 'ddavids', 264 | password: 'dontknow', 265 | }); 266 | 267 | expect(errors[0].message).to.eql('Invalid password.'); 268 | }); 269 | }); 270 | 271 | it('returns an error when a user is not found', async () => { 272 | const { 273 | data: { errors }, 274 | } = await api.signIn({ 275 | login: 'dontknow', 276 | password: 'ddavids', 277 | }); 278 | 279 | expect(errors[0].message).to.eql( 280 | 'No user found with this login credentials.', 281 | ); 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /client/src/components/UsersTab/UsersTab.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { useQuery, useMutation } from '@apollo/react-hooks'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import List from '@material-ui/core/List'; 7 | import ListItem from '@material-ui/core/ListItem'; 8 | import Divider from '@material-ui/core/Divider'; 9 | import ListItemText from '@material-ui/core/ListItemText'; 10 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 11 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; 12 | import Avatar from '@material-ui/core/Avatar'; 13 | import Typography from '@material-ui/core/Typography'; 14 | import Button from '@material-ui/core/Button'; 15 | import CircularProgress from '@material-ui/core/CircularProgress'; 16 | import Link from '@material-ui/core/Link'; 17 | import Fade from '@material-ui/core/Fade'; 18 | import Popper from '@material-ui/core/Popper'; 19 | 20 | import UserCard from '../UserCard/UserCard'; 21 | 22 | import { 23 | GET_FRIENDS, 24 | GET_REFETCH_FOLLOWERS, 25 | } from '../../graphql/queries'; 26 | import { 27 | FOLLOW_USER, 28 | UNFOLLOW_USER, 29 | SET_REFETCH_FOLLOWERS, 30 | } from '../../graphql/mutations'; 31 | import { UPLOADS_IMAGES_FOLDER } from '../../constants/paths'; 32 | 33 | const useStyles = makeStyles(theme => ({ 34 | root: { 35 | width: '100%', 36 | backgroundColor: theme.palette.background.paper, 37 | }, 38 | secondaryAction: { 39 | top: '27%', 40 | }, 41 | noUsers: { 42 | padding: theme.spacing(2), 43 | textAlign: 'center', 44 | }, 45 | })); 46 | 47 | const UsersTab = ({ 48 | username, 49 | isFollowers, 50 | isFollowing, 51 | session, 52 | }) => { 53 | const classes = useStyles(); 54 | 55 | const { data, error, loading, refetch } = useQuery(GET_FRIENDS, { 56 | variables: { username, isFollowers, isFollowing, limit: 10 }, 57 | }); 58 | 59 | const { 60 | data: { 61 | refetchFollowers: { signal }, 62 | }, 63 | } = useQuery(GET_REFETCH_FOLLOWERS); 64 | 65 | useEffect(() => { 66 | refetch(); 67 | }, [signal]); 68 | 69 | const [anchorEl, setAnchorEl] = useState(null); 70 | const [popUpEntered, setPopUpEntered] = useState(false); 71 | const [nameEntered, setNameEntered] = useState(false); 72 | const [usernameState, setUsernameState] = useState(''); 73 | 74 | useEffect(() => { 75 | setTimeout(() => { 76 | if (!popUpEntered && !nameEntered) { 77 | setAnchorEl(null); 78 | setPopUpEntered(false); 79 | setNameEntered(false); 80 | } 81 | }, 300); 82 | }, [popUpEntered, nameEntered]); 83 | 84 | const [followUser] = useMutation(FOLLOW_USER); 85 | const [unfollowUser] = useMutation(UNFOLLOW_USER); 86 | const [setRefetchFollowers] = useMutation(SET_REFETCH_FOLLOWERS); 87 | 88 | const handleFollow = async user => { 89 | await followUser({ variables: { username: user.username } }); 90 | setRefetchFollowers(); 91 | }; 92 | const handleUnfollow = async user => { 93 | await unfollowUser({ variables: { username: user.username } }); 94 | setRefetchFollowers(); 95 | }; 96 | 97 | const handleMouseEnter = (event, user) => { 98 | setAnchorEl(event.currentTarget); 99 | setNameEntered(true); 100 | setUsernameState(user.username); 101 | }; 102 | const handleMouseLeave = event => { 103 | setNameEntered(false); 104 | }; 105 | 106 | const handlePopUpMouseEnter = event => { 107 | setPopUpEntered(true); 108 | }; 109 | const handlePopUpMouseLeave = event => { 110 | setPopUpEntered(false); 111 | }; 112 | 113 | // console.log(data, error); 114 | if (loading) return ; 115 | const { friends: users } = data; 116 | 117 | return ( 118 | <> 119 | 125 | {({ TransitionProps }) => ( 126 | 127 | 133 | 134 | )} 135 | 136 | 137 | {users.length === 0 && ( 138 |
139 | There are no users ... 140 |
141 | )} 142 | <> 143 | {users.map((user, index) => { 144 | return ( 145 | 146 | 147 | 148 | 152 | 156 | 157 | 158 | handleMouseEnter(e, user)} 165 | onMouseLeave={handleMouseLeave} 166 | > 167 | {user.name} 168 | 169 | } 170 | secondary={ 171 | 172 | 177 | @{user.username} 178 | 179 | 185 | {user.bio} 186 | 187 | 188 | } 189 | /> 190 | 193 | {user.isFollowHim ? ( 194 | 202 | ) : ( 203 | 211 | )} 212 | 213 | 214 | {index !== users.length - 1 && ( 215 | 216 | )} 217 | 218 | ); 219 | })} 220 | 221 |
222 | 223 | ); 224 | }; 225 | 226 | export default UsersTab; 227 | -------------------------------------------------------------------------------- /server/src/resolvers/user.js: -------------------------------------------------------------------------------- 1 | import mongoose, { models } from 'mongoose'; 2 | import jwt from 'jsonwebtoken'; 3 | import { combineResolvers } from 'graphql-resolvers'; 4 | import { AuthenticationError, UserInputError } from 'apollo-server'; 5 | 6 | import pubsub, { EVENTS } from '../subscription'; 7 | import { isAdmin, isAuthenticated } from './authorization'; 8 | import { processFile } from '../utils/upload'; 9 | const ObjectId = mongoose.Types.ObjectId; 10 | 11 | const createToken = async (user, secret, expiresIn) => { 12 | const { id, email, username, role } = user; 13 | return await jwt.sign({ id, email, username, role }, secret, { 14 | expiresIn, 15 | }); 16 | }; 17 | 18 | const publishUserNotification = async (owner, user, action) => { 19 | const notification = await models.Notification.create({ 20 | ownerId: owner.id, 21 | messageId: null, 22 | userId: user.id, 23 | action, 24 | }); 25 | 26 | pubsub.publish(EVENTS.NOTIFICATION.CREATED, { 27 | notificationCreated: { notification }, 28 | }); 29 | 30 | const unseenNotificationsCount = await models.Notification.find({ 31 | ownerId: notification.ownerId, 32 | isSeen: false, 33 | }).countDocuments(); 34 | 35 | pubsub.publish(EVENTS.NOTIFICATION.NOT_SEEN_UPDATED, { 36 | notSeenUpdated: unseenNotificationsCount, 37 | ownerId: notification.ownerId, 38 | }); 39 | }; // 40 | 41 | export default { 42 | Query: { 43 | // who to follow filter koje vec ne pratis 44 | whoToFollow: async (parent, { limit = 10 }, { models, me }) => { 45 | const filter = me 46 | ? { 47 | _id: { 48 | $ne: ObjectId(me.id), 49 | }, 50 | followersIds: { $ne: ObjectId(me.id) }, // comment this to stay with unfollow button 51 | } 52 | : {}; 53 | return await models.User.find(filter, null, { 54 | limit, 55 | }); 56 | }, 57 | friends: async ( 58 | parent, 59 | { username, isFollowing, isFollowers, limit = 10 }, 60 | { models, me }, 61 | ) => { 62 | const user = await models.User.findOne({ username }); 63 | 64 | const filter = { 65 | // ...(me && { 66 | // _id: { $ne: ObjectId(me.id) }, 67 | // }), 68 | ...(user && 69 | isFollowing && { 70 | followersIds: { $in: [user.id] }, 71 | }), 72 | ...(user && 73 | isFollowers && { 74 | followingIds: { $in: [user.id] }, 75 | }), 76 | }; 77 | 78 | return await models.User.find(filter, null, { 79 | limit, 80 | }); 81 | }, 82 | user: async (parent, { username }, { models }) => { 83 | return await models.User.findOne({ username }); 84 | }, 85 | me: async (parent, args, { models, me }) => { 86 | if (!me) { 87 | return null; 88 | } 89 | const user = await models.User.findById(me.id); 90 | return user; 91 | }, 92 | }, 93 | 94 | Mutation: { 95 | signUp: async ( 96 | parent, 97 | { username, email, password }, 98 | { models, secret }, 99 | ) => { 100 | const user = await models.User.create({ 101 | username, 102 | email, 103 | password, 104 | }); 105 | 106 | return { token: createToken(user, secret, '300m') }; 107 | }, 108 | 109 | signIn: async ( 110 | parent, 111 | { login, password }, 112 | { models, secret }, 113 | ) => { 114 | const user = await models.User.findByLogin(login); 115 | 116 | if (!user) { 117 | throw new UserInputError( 118 | 'No user found with this login credentials.', 119 | ); 120 | } 121 | 122 | const isValid = await user.validatePassword(password); 123 | 124 | if (!isValid) { 125 | throw new AuthenticationError('Invalid password.'); 126 | } 127 | 128 | return { token: createToken(user, secret, '300m') }; 129 | }, 130 | 131 | updateUser: combineResolvers( 132 | isAuthenticated, 133 | async ( 134 | parent, 135 | { name, bio, avatar, cover }, 136 | { models, me }, 137 | ) => { 138 | const avatarSaved = avatar 139 | ? await processFile(avatar) 140 | : undefined; 141 | const coverSaved = cover 142 | ? await processFile(cover) 143 | : undefined; 144 | 145 | const user = { 146 | name, 147 | bio, 148 | avatarId: avatarSaved && avatarSaved.id, 149 | coverId: coverSaved && coverSaved.id, 150 | }; 151 | 152 | for (let prop in user) if (!user[prop]) delete user[prop]; 153 | 154 | return await models.User.findByIdAndUpdate( 155 | me.id, 156 | { 157 | $set: user, 158 | }, 159 | { new: true, useFindAndModify: false }, 160 | ); 161 | }, 162 | ), 163 | 164 | deleteUser: combineResolvers( 165 | isAdmin, // samo admin moze da brise 166 | async (parent, { id }, { models }) => { 167 | const user = await models.User.findById(id); 168 | 169 | if (user) { 170 | await user.remove(); 171 | return true; 172 | } else { 173 | return false; 174 | } 175 | }, 176 | ), 177 | 178 | followUser: combineResolvers( 179 | isAuthenticated, 180 | async (parent, { username }, { models, me }) => { 181 | // dont follow myself 182 | if (username === me.username) return false; 183 | 184 | const followedUser = await models.User.findOneAndUpdate( 185 | { username }, 186 | { $push: { followersIds: me.id } }, 187 | ); 188 | 189 | if (!followedUser) return false; 190 | const followingUser = await models.User.findOneAndUpdate( 191 | { _id: me.id }, 192 | { $push: { followingIds: followedUser.id } }, 193 | ); 194 | await publishUserNotification(followedUser, me, 'follow'); 195 | 196 | return !!followingUser; 197 | }, 198 | ), 199 | 200 | unfollowUser: combineResolvers( 201 | isAuthenticated, 202 | async (parent, { username }, { models, me }) => { 203 | if (username === me.username) return false; 204 | const unfollowedUser = await models.User.findOneAndUpdate( 205 | { username }, 206 | { $pull: { followersIds: me.id } }, 207 | ); 208 | 209 | if (!unfollowedUser) return false; 210 | 211 | const followingUser = await models.User.findOneAndUpdate( 212 | { _id: me.id }, 213 | { $pull: { followingIds: unfollowedUser.id } }, 214 | ); 215 | await publishUserNotification(unfollowedUser, me, 'unfollow'); 216 | 217 | return !!followingUser; 218 | }, 219 | ), 220 | }, 221 | 222 | User: { 223 | messages: async (user, args, { models }) => { 224 | return await models.Message.find({ 225 | userId: user.id, 226 | }); 227 | }, 228 | avatar: async (user, args, { models }) => { 229 | return await models.File.findById(user.avatarId); 230 | }, 231 | cover: async (user, args, { models }) => { 232 | return await models.File.findById(user.coverId); 233 | }, 234 | followers: async (user, args, { models }) => { 235 | return await models.User.find({ 236 | followingIds: { $in: [user.id] }, 237 | }); 238 | }, 239 | following: async (user, args, { models }) => { 240 | return await models.User.find({ 241 | followersIds: { $in: [user.id] }, 242 | }); 243 | }, 244 | followersCount: async (user, args, { models }) => { 245 | const followers = await models.User.find({ 246 | followingIds: { $in: [user.id] }, 247 | }); 248 | return followers.length; 249 | }, 250 | followingCount: async (user, args, { models }) => { 251 | const following = await models.User.find({ 252 | followersIds: { $in: [user.id] }, 253 | }); 254 | return following.length; 255 | }, 256 | messagesCount: async (user, args, { models }) => { 257 | const messages = await models.Message.find({ 258 | userId: user.id, 259 | }); 260 | return messages.length; 261 | }, 262 | isFollowHim: async (user, args, { models, me }) => { 263 | if (!me) return false; 264 | 265 | const followers = await models.User.find({ 266 | followingIds: { $in: [user.id] }, 267 | }); 268 | const amIFollowing = !!followers.find( 269 | user => user.username === me?.username, 270 | ); 271 | return amIFollowing; 272 | }, 273 | isFollowsMe: async (user, args, { models, me }) => { 274 | if (!me) return false; 275 | 276 | const following = await models.User.find({ 277 | followersIds: { $in: [user.id] }, 278 | }); 279 | const amIFollowed = !!following.find( 280 | user => user.username === me?.username, 281 | ); 282 | return amIFollowed; 283 | }, 284 | }, 285 | }; 286 | -------------------------------------------------------------------------------- /client/src/components/Notifications/Notifications.js: -------------------------------------------------------------------------------- 1 | import React, { 2 | Fragment, 3 | useEffect, 4 | useCallback, 5 | useState, 6 | } from 'react'; 7 | import { Link as RouterLink } from 'react-router-dom'; 8 | import { useQuery } from '@apollo/react-hooks'; 9 | import moment from 'moment'; 10 | 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | import List from '@material-ui/core/List'; 13 | import ListItem from '@material-ui/core/ListItem'; 14 | import Divider from '@material-ui/core/Divider'; 15 | import ListItemText from '@material-ui/core/ListItemText'; 16 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 17 | import Avatar from '@material-ui/core/Avatar'; 18 | import Typography from '@material-ui/core/Typography'; 19 | import Grid from '@material-ui/core/Grid'; 20 | import Link from '@material-ui/core/Link'; 21 | import Badge from '@material-ui/core/Badge'; 22 | import FavoriteIcon from '@material-ui/icons/Favorite'; 23 | import RepeatIcon from '@material-ui/icons/Repeat'; 24 | import PersonIcon from '@material-ui/icons/Person'; 25 | import Fade from '@material-ui/core/Fade'; 26 | import Popper from '@material-ui/core/Popper'; 27 | 28 | import UserCard from '../UserCard/UserCard'; 29 | import Loading from '../Loading/Loading'; 30 | 31 | import { GET_PAGINATED_NOTIFICATIONS } from '../../graphql/queries'; 32 | import { NOTIFICATION_CREATED } from '../../graphql/subscriptions'; 33 | import { UPLOADS_IMAGES_FOLDER } from '../../constants/paths'; 34 | 35 | const useStyles = makeStyles(theme => ({ 36 | root: { 37 | width: '100%', 38 | backgroundColor: theme.palette.background.paper, 39 | }, 40 | username: {}, 41 | timeAgo: { marginTop: theme.spacing(1) }, 42 | icon: { fontSize: '0.7rem' }, 43 | noNotifications: { 44 | padding: theme.spacing(2), 45 | textAlign: 'center', 46 | }, 47 | popper: { 48 | zIndex: 4, 49 | }, 50 | })); 51 | 52 | const Notifications = ({ session }) => { 53 | const classes = useStyles(); 54 | 55 | const { 56 | data, 57 | loading, 58 | error, 59 | refetch, 60 | fetchMore, 61 | subscribeToMore, 62 | } = useQuery(GET_PAGINATED_NOTIFICATIONS, { 63 | variables: { limit: 10 }, 64 | }); 65 | 66 | const [anchorEl, setAnchorEl] = useState(null); 67 | const [popUpEntered, setPopUpEntered] = useState(false); 68 | const [nameEntered, setNameEntered] = useState(false); 69 | const [username, setUsername] = useState(''); 70 | 71 | useEffect(() => { 72 | setTimeout(() => { 73 | if (!popUpEntered && !nameEntered) { 74 | setAnchorEl(null); 75 | setPopUpEntered(false); 76 | setNameEntered(false); 77 | } 78 | }, 300); 79 | }, [popUpEntered, nameEntered]); 80 | 81 | const handleMouseEnter = (event, username) => { 82 | setAnchorEl(event.currentTarget); 83 | setNameEntered(true); 84 | setUsername(username); 85 | }; 86 | const handleMouseLeave = event => { 87 | setNameEntered(false); 88 | }; 89 | const handlePopUpMouseEnter = event => { 90 | setPopUpEntered(true); 91 | }; 92 | const handlePopUpMouseLeave = event => { 93 | setPopUpEntered(false); 94 | }; 95 | 96 | const subscribeToMoreNotification = useCallback(() => { 97 | subscribeToMore({ 98 | document: NOTIFICATION_CREATED, 99 | variables: {}, 100 | updateQuery: (previousResult, { subscriptionData }) => { 101 | if (!subscriptionData.data) { 102 | return previousResult; 103 | } 104 | 105 | const { notificationCreated } = subscriptionData.data; 106 | 107 | return { 108 | ...previousResult, 109 | notifications: { 110 | ...previousResult.notifications, 111 | edges: [ 112 | notificationCreated.notification, 113 | ...previousResult.notifications.edges, 114 | ], 115 | }, 116 | }; 117 | }, 118 | }); 119 | }, [subscribeToMore]); 120 | 121 | useEffect(() => { 122 | subscribeToMoreNotification(); 123 | }, [subscribeToMoreNotification]); 124 | 125 | useEffect(() => { 126 | refetch(); 127 | }, [refetch]); 128 | 129 | const getActionText = action => { 130 | switch (action) { 131 | case 'like': 132 | return { 133 | text: 'liked your message', 134 | color: 'primary', 135 | icon: , 136 | }; 137 | case 'unlike': 138 | return { 139 | text: 'unliked your message', 140 | color: 'secondary', 141 | icon: , 142 | }; 143 | case 'repost': 144 | return { 145 | text: 'reposted your message', 146 | color: 'primary', 147 | icon: , 148 | }; 149 | case 'unrepost': 150 | return { 151 | text: 'unreposted your message', 152 | color: 'secondary', 153 | icon: , 154 | }; 155 | case 'follow': 156 | return { 157 | text: 'followed you', 158 | color: 'primary', 159 | icon: , 160 | }; 161 | case 'unfollow': 162 | return { 163 | text: 'unfollowed you', 164 | color: 'secondary', 165 | icon: , 166 | }; 167 | } 168 | }; 169 | 170 | if (loading) { 171 | return ; 172 | } 173 | 174 | const { edges, pageInfo } = data.notifications; 175 | 176 | //console.log(data, error); 177 | 178 | return ( 179 | <> 180 | 187 | {({ TransitionProps }) => ( 188 | 189 | 195 | 196 | )} 197 | 198 | 199 | {edges.length === 0 && ( 200 |
201 | There are no notifications yet ... 202 |
203 | )} 204 | <> 205 | {edges.map((notification, index) => { 206 | return ( 207 | 208 | 209 | 210 | 216 | 220 | 224 | 225 | 226 | 227 | 230 | 236 | handleMouseEnter( 237 | e, 238 | notification.user.username, 239 | ) 240 | } 241 | onMouseLeave={handleMouseLeave} 242 | > 243 | {notification.user.name} 244 | {' '} 245 | 250 | {getActionText(notification.action).text} 251 | 252 | 253 | } 254 | secondary={ 255 | 260 | {moment(notification.createdAt).fromNow()} 261 | 262 | } 263 | /> 264 | 265 | {index !== edges.length - 1 && ( 266 | 267 | )} 268 | 269 | ); 270 | })} 271 | 272 |
273 | 274 | ); 275 | }; 276 | 277 | export default Notifications; 278 | -------------------------------------------------------------------------------- /client/src/components/Navigation/Navigation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { 4 | useMutation, 5 | useSubscription, 6 | useQuery, 7 | } from '@apollo/react-hooks'; 8 | 9 | import AppBar from '@material-ui/core/AppBar'; 10 | import Toolbar from '@material-ui/core/Toolbar'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import { makeStyles } from '@material-ui/core/styles'; 13 | import CameraIcon from '@material-ui/icons/PhotoCamera'; 14 | import Button from '@material-ui/core/Button'; 15 | import Tabs from '@material-ui/core/Tabs'; 16 | import Tab from '@material-ui/core/Tab'; 17 | import HomeIcon from '@material-ui/icons/Home'; 18 | import AccountCircleIcon from '@material-ui/icons/AccountCircle'; 19 | import NotificationsIcon from '@material-ui/icons/Notifications'; 20 | import SettingsIcon from '@material-ui/icons/Settings'; 21 | import Badge from '@material-ui/core/Badge'; 22 | import Brightness4Icon from '@material-ui/icons/Brightness4'; 23 | import ExitToAppIcon from '@material-ui/icons/ExitToApp'; 24 | import Menu from '@material-ui/core/Menu'; 25 | import MenuItem from '@material-ui/core/MenuItem'; 26 | import { styled } from '@material-ui/core/styles'; 27 | import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'; 28 | 29 | import * as routes from '../../constants/routes'; 30 | import SignOutButton from '../SignOutButton/SignOutButton'; 31 | import { SET_THEME } from '../../graphql/mutations'; 32 | import { GET_NOT_SEEN_NOTIFICATIONS_COUNT } from '../../graphql/queries'; 33 | import { NOT_SEEN_UPDATED } from '../../graphql/subscriptions'; 34 | 35 | const StyledLink = styled(Link)({ 36 | minHeight: 64, 37 | minWidth: 'auto', 38 | }); 39 | 40 | const TabPanel = props => { 41 | const { children, value, index, ...other } = props; 42 | 43 | return ( 44 | 54 | ); 55 | }; 56 | 57 | const useStyles = makeStyles(theme => ({ 58 | icon: { 59 | marginRight: theme.spacing(2), 60 | }, 61 | title: { 62 | marginRight: theme.spacing(2), 63 | fontWeight: 700, 64 | }, 65 | flex: { 66 | flex: 1, 67 | }, 68 | label: { 69 | color: theme.palette.primary.contrastText, 70 | display: 'flex', 71 | alignContent: 'center', 72 | justifyContent: 'space-around', 73 | fontSize: '0.875rem', 74 | fontWeight: 500, 75 | lineHeight: 1.75, 76 | letterSpacing: '0.02857em', 77 | }, 78 | typographyLabel: { 79 | fontSize: '0.875rem', 80 | fontWeight: 500, 81 | lineHeight: 1.75, 82 | letterSpacing: '0.02857em', 83 | marginLeft: theme.spacing(1), 84 | }, 85 | indicator: { 86 | backgroundColor: theme.palette.primary.contrastText, 87 | }, 88 | tabs: { 89 | maxWidth: 1000, 90 | }, 91 | })); 92 | 93 | const Navigation = ({ session, match }) => { 94 | const classes = useStyles(); 95 | 96 | const [anchorEl, setAnchorEl] = React.useState(null); 97 | 98 | const [setTheme] = useMutation(SET_THEME); 99 | 100 | const { data, loading, error, subscribeToMore } = useQuery( 101 | GET_NOT_SEEN_NOTIFICATIONS_COUNT, 102 | { 103 | // variables: {}, 104 | skip: !session?.me?.username, // tu je zajebancija, subscript mora u deps u useefect 105 | }, 106 | ); 107 | 108 | useEffect(() => { 109 | subscribeToMore({ 110 | document: NOT_SEEN_UPDATED, 111 | variables: {}, 112 | updateQuery: (previousResult, { subscriptionData }) => { 113 | // console.log('subscriptionData', subscriptionData); 114 | 115 | if (!subscriptionData.data) { 116 | return previousResult; 117 | } 118 | const { notSeenUpdated } = subscriptionData.data; 119 | 120 | return { 121 | notSeenNotificationsCount: notSeenUpdated, 122 | }; 123 | }, 124 | }); 125 | }, [subscribeToMore, session?.me?.username]); // tu je zajebancija bila, mora session i skip u query 126 | 127 | // console.log(data, loading, error); 128 | 129 | if (loading) { 130 | return null; 131 | } 132 | 133 | const handleMenuOpen = event => { 134 | setAnchorEl(event.currentTarget); 135 | }; 136 | 137 | const handleClose = () => { 138 | setAnchorEl(null); 139 | }; 140 | 141 | const handleMenu = async (type, color) => { 142 | await setTheme({ 143 | variables: { 144 | type, 145 | color, 146 | }, 147 | }); 148 | setAnchorEl(null); 149 | }; 150 | 151 | const getActiveTabIndex = match => { 152 | if (!session?.me) return false; 153 | if (match?.params?.username === session?.me?.username) return 1; 154 | if (match?.url === '/') return 0; 155 | if (match?.url === '/notifications') return 2; 156 | if (match?.url === '/admin') return 3; 157 | return false; 158 | }; 159 | //console.log(match); 160 | 161 | return ( 162 | <> 163 | 164 | 165 | 166 | 172 | Audio Twitter 173 | 174 | 180 | 185 | 186 | 190 | Home 191 | 192 | 193 | } 194 | /> 195 | {session?.me && [ 196 | 202 | 203 | 207 | Profile 208 | 209 | 210 | } 211 | />, 212 | 218 | 229 | 230 | 231 | 235 | Notifications 236 | 237 | 238 | } 239 | />, 240 | ]} 241 | {session?.me?.role === 'ADMIN' && ( 242 | 247 | 248 | 252 | Admin 253 | 254 | 255 | } 256 | /> 257 | )} 258 | 259 |
260 | 267 | 274 | handleMenu('light', 'green')}> 275 | Light green 276 | 277 | handleMenu('light', 'orange')}> 278 | Light orange 279 | 280 | handleMenu('dark', 'green')}> 281 | Dark green 282 | 283 | handleMenu('dark', 'orange')}> 284 | Dark orange 285 | 286 | 287 | {session && session.me ? ( 288 | <> 289 | 290 | 291 | ({session.me.username}) 292 | 293 | 294 | ) : ( 295 | <> 296 | 303 | 310 | 311 | )} 312 |
313 |
314 | 315 | ); 316 | }; 317 | 318 | export default Navigation; 319 | -------------------------------------------------------------------------------- /client/src/components/Microphone/Microphone.js: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | useCallback, 6 | } from 'react'; 7 | import { ReactMic } from 'react-mic'; 8 | import WaveSurfer from 'wavesurfer.js'; 9 | import { useMutation } from '@apollo/react-hooks'; 10 | 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | import MicIcon from '@material-ui/icons/Mic'; 13 | import IconButton from '@material-ui/core/IconButton'; 14 | import StopIcon from '@material-ui/icons/Stop'; 15 | import ReplayIcon from '@material-ui/icons/Replay'; 16 | import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; 17 | import DoneIcon from '@material-ui/icons/Done'; 18 | import CancelIcon from '@material-ui/icons/Cancel'; 19 | import PlayArrowIcon from '@material-ui/icons/PlayArrow'; 20 | import PauseIcon from '@material-ui/icons/Pause'; 21 | 22 | import Button from '@material-ui/core/Button'; 23 | import Dialog from '@material-ui/core/Dialog'; 24 | import DialogActions from '@material-ui/core/DialogActions'; 25 | import DialogContent from '@material-ui/core/DialogContent'; 26 | import DialogContentText from '@material-ui/core/DialogContentText'; 27 | import DialogTitle from '@material-ui/core/DialogTitle'; 28 | import Grid from '@material-ui/core/Grid'; 29 | import { green, red, blue } from '@material-ui/core/colors'; 30 | import Fab from '@material-ui/core/Fab'; 31 | import CircularProgress from '@material-ui/core/CircularProgress'; 32 | import LinearProgress from '@material-ui/core/LinearProgress'; 33 | import { useTheme } from '@material-ui/styles'; 34 | 35 | import ErrorMessage from '../Error/Error'; 36 | import { CREATE_MESSAGE } from '../../graphql/mutations'; 37 | 38 | import './microphone.css'; 39 | 40 | const useStyles = makeStyles(theme => ({ 41 | icon: { 42 | height: 38, 43 | width: 38, 44 | }, 45 | reactmic: { 46 | width: '100%', 47 | height: 200, 48 | }, 49 | wavesurfer: { 50 | width: '100%', 51 | }, 52 | flex: { 53 | flex: 1, 54 | }, 55 | fab: { 56 | position: 'fixed', 57 | bottom: theme.spacing(2), 58 | right: theme.spacing(2), 59 | zIndex: 2, 60 | }, 61 | progress: { 62 | display: 'block', 63 | margin: theme.spacing(2), 64 | width: '100%', 65 | }, 66 | circularProgress: { 67 | marginBottom: theme.spacing(1), 68 | }, 69 | })); 70 | 71 | const Microphone = () => { 72 | const theme = useTheme(); 73 | const [record, setRecord] = useState(false); 74 | const [open, setOpen] = React.useState(false); 75 | const [tempFile, setTempFile] = React.useState(null); 76 | 77 | const [playerReady, setPlayerReady] = useState(false); 78 | const [isPlaying, setIsPlaying] = useState(false); 79 | const wavesurfer = useRef(null); 80 | 81 | const [completed, setCompleted] = React.useState(0); 82 | const [secondsRemaining, setSecondsRemaining] = React.useState(0); 83 | 84 | const [createMessage, { error }] = useMutation(CREATE_MESSAGE); 85 | 86 | useEffect(() => { 87 | if (!record) { 88 | setSecondsRemaining(0); 89 | return; 90 | } 91 | 92 | const progressFn = () => { 93 | setSecondsRemaining(oldSecondsRemaining => { 94 | if (oldSecondsRemaining === 100) { 95 | setRecord(false); 96 | return 0; 97 | } 98 | return oldSecondsRemaining + 5; //0 to 100, for 20 sec 99 | }); 100 | }; 101 | 102 | const timer = setInterval(progressFn, 1000); 103 | return () => { 104 | clearInterval(timer); 105 | }; 106 | }, [record]); 107 | 108 | useEffect(() => { 109 | if (!open || (open && !tempFile)) return; 110 | 111 | wavesurfer.current = WaveSurfer.create({ 112 | container: '#wavesurfer-id', 113 | waveColor: `${theme.palette.text.secondary}`, 114 | progressColor: `${theme.palette.secondary.main}`, 115 | cursorColor: `${theme.palette.text.primary}`, 116 | height: 140, 117 | cursorWidth: 1, 118 | barWidth: 2, 119 | normalize: true, 120 | responsive: true, 121 | fillParent: true, 122 | }); 123 | 124 | wavesurfer.current.on('ready', () => { 125 | setPlayerReady(true); 126 | }); 127 | wavesurfer.current.on('play', () => setIsPlaying(true)); 128 | wavesurfer.current.on('pause', () => setIsPlaying(false)); 129 | }, [open, tempFile]); 130 | 131 | useEffect(() => { 132 | if (tempFile && wavesurfer.current) { 133 | wavesurfer.current.load(tempFile.blobURL); 134 | } else { 135 | wavesurfer.current = null; 136 | setTempFile(null); 137 | } 138 | }, [tempFile]); 139 | 140 | const togglePlayback = () => { 141 | if (!isPlaying) { 142 | wavesurfer.current.play(); 143 | } else { 144 | wavesurfer.current.pause(); 145 | } 146 | }; 147 | const stopPlayback = () => wavesurfer.current.stop(); 148 | 149 | const handleClickOpen = () => { 150 | setOpen(true); 151 | }; 152 | 153 | const BlobURLToFile = async tempFile => { 154 | const response = await fetch(tempFile.blobURL); 155 | const data = await response.blob(); 156 | const metadata = { 157 | type: 'audio/webm', 158 | }; 159 | const file = new File([data], 'mic_recording.webm', metadata); 160 | return file; 161 | }; 162 | 163 | const handleDone = async () => { 164 | let abort; 165 | if (tempFile) { 166 | try { 167 | const file = await BlobURLToFile(tempFile); 168 | 169 | await createMessage({ 170 | variables: { file }, 171 | context: { 172 | fetchOptions: { 173 | useUpload: true, 174 | onProgress: e => { 175 | setCompleted(Math.floor(e.loaded / e.total)); 176 | }, 177 | onAbortPossible: abortHandler => { 178 | abort = abortHandler; 179 | }, 180 | }, 181 | }, 182 | }); 183 | 184 | setTempFile(null); 185 | setRecord(false); 186 | setOpen(false); 187 | setCompleted(0); 188 | } catch (error) { 189 | console.log(error); 190 | } 191 | } 192 | }; 193 | 194 | const handleCancel = () => { 195 | setRecord(false); 196 | setTempFile(null); 197 | setOpen(false); 198 | setSecondsRemaining(0); 199 | }; 200 | 201 | const startRecording = () => { 202 | setTempFile(null); 203 | setRecord(true); 204 | }; 205 | 206 | const stopRecording = () => { 207 | setRecord(false); 208 | }; 209 | 210 | const onData = recordedBlob => { 211 | //console.log("chunk of real-time data is: ", recordedBlob); 212 | }; 213 | 214 | const onStop = recordedBlob => { 215 | setTempFile(recordedBlob); 216 | }; 217 | 218 | const classes = useStyles(); 219 | 220 | return ( 221 | <> 222 | 223 | 224 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | {completed === 0 && record && ( 236 | 237 | {`Recording, ${20 - 238 | Math.floor(secondsRemaining / 5)} seconds left`} 239 | 240 | )} 241 | {completed === 0 && !record && Record} 242 | {completed > 0 && {`Uploading ${completed}%`}} 243 | 244 | 245 | {tempFile ? ( 246 |
247 | ) : ( 248 | 256 | )} 257 | 258 | 259 | 260 | {record && ( 261 | 268 | 272 | 273 | )} 274 | {tempFile && ( 275 | 276 | {!isPlaying ? ( 277 | 278 | 279 | 280 | ) : ( 281 | 282 | 283 | 284 | )} 285 | 286 | 287 | 288 | 289 | )} 290 | 291 | {!record && !tempFile && ( 292 | 293 | 297 | 298 | )} 299 | 300 | {!record && tempFile && ( 301 | 302 | 303 | 304 | )} 305 | 306 | {record && ( 307 | 308 | 309 | 310 | )} 311 | 312 | 313 | 319 | 320 | 321 | 327 | 328 | 329 | {completed > 0 && ( 330 |
331 | 335 |
336 | )} 337 |
338 |
339 |
340 | 341 | ); 342 | }; 343 | 344 | export default Microphone; 345 | -------------------------------------------------------------------------------- /server/src/resolvers/message.js: -------------------------------------------------------------------------------- 1 | import mongoose, { models } from 'mongoose'; 2 | import { combineResolvers } from 'graphql-resolvers'; 3 | import { withFilter } from 'apollo-server'; 4 | 5 | import pubsub, { EVENTS } from '../subscription'; 6 | import { isAuthenticated, isMessageOwner } from './authorization'; 7 | import { processFile, deleteFile } from '../utils/upload'; 8 | const ObjectId = mongoose.Types.ObjectId; 9 | 10 | // to base64, da klijent aplikacija ne bi radila sa datumom nego sa stringom 11 | const toCursorHash = string => 12 | string ? Buffer.from(string).toString('base64') : ''; 13 | 14 | // from base64 15 | const fromCursorHash = string => 16 | Buffer.from(string, 'base64').toString('ascii'); 17 | 18 | const publishMessageNotification = async (message, me, action) => { 19 | const notification = await models.Notification.create({ 20 | ownerId: message.userId, 21 | messageId: message.id, 22 | userId: me.id, 23 | action, 24 | }); 25 | 26 | await pubsub.publish(EVENTS.NOTIFICATION.CREATED, { 27 | notificationCreated: { notification }, 28 | }); 29 | 30 | const unseenNotificationsCount = await models.Notification.find({ 31 | ownerId: notification.ownerId, 32 | isSeen: false, 33 | }).countDocuments(); 34 | 35 | console.log('unseenNotificationsCount', unseenNotificationsCount); 36 | 37 | await pubsub.publish(EVENTS.NOTIFICATION.NOT_SEEN_UPDATED, { 38 | notSeenUpdated: unseenNotificationsCount, 39 | ownerId: notification.ownerId, 40 | }); 41 | }; 42 | 43 | export default { 44 | Query: { 45 | // kursor je vazan za vrednost podatka, a ne index elementa kao u offset/limmit paginaciji 46 | // kad se izbrise iz izvadjenih offset postaje nevalidan, a kursor ostaje uvek isti 47 | // createdAt za cursor 48 | messages: async ( 49 | parent, 50 | { cursor, limit = 100, username }, 51 | { models, me }, 52 | ) => { 53 | //user cija je profile stranica 54 | const user = username 55 | ? await models.User.findOne({ 56 | username, 57 | }) 58 | : null; 59 | 60 | //me je user sa clienta, iz tokena, logovani user 61 | const meUser = me ? await models.User.findById(me.id) : null; 62 | //console.log(meUser); 63 | 64 | const cursorTime = cursor 65 | ? new Date(fromCursorHash(cursor)) //.toISOString() 66 | : null; 67 | 68 | // console.log(cursor, cursorTime); 69 | 70 | const match = { 71 | // za prvi upit ne treba cursor 72 | ...(cursorTime && { 73 | createdAt: { 74 | $lt: cursorTime, //MORA NEW DATE(), sa toISOString ne radi 75 | }, 76 | }), 77 | // user page 78 | ...(!!user && { 79 | $or: [ 80 | { 81 | userId: user._id, //njegovi 82 | }, 83 | { 84 | 'repost.reposterId': user._id, // ili tudji koje je on rt 85 | }, 86 | ], 87 | }), 88 | // timeline, see messages only from following and me 89 | ...(!!meUser && 90 | !username && { 91 | $or: [ 92 | { 93 | userId: { 94 | $in: [...meUser.followingIds, meUser._id], 95 | }, 96 | }, 97 | { 98 | 'reposts.reposterId': { 99 | $in: [...meUser.followingIds, meUser._id], //rt-ovi onih koje pratim i moji 100 | }, 101 | }, 102 | ], 103 | }), 104 | ...(!meUser && !username && {}), //timeline for non loged user, all tweets 105 | }; 106 | // console.log(match); 107 | 108 | const messages = await models.Message.aggregate([ 109 | { 110 | $addFields: { 111 | id: { $toString: '$_id' }, 112 | }, 113 | }, 114 | { $match: match }, 115 | { 116 | $sort: { 117 | createdAt: -1, 118 | }, 119 | }, 120 | { 121 | $limit: limit + 1, 122 | }, 123 | ]); 124 | 125 | // console.log(messages); 126 | 127 | const hasNextPage = messages.length > limit; 128 | const edges = hasNextPage ? messages.slice(0, -1) : messages; //-1 exclude zadnji 129 | 130 | return { 131 | edges, 132 | pageInfo: { 133 | hasNextPage, 134 | endCursor: toCursorHash( 135 | edges[edges.length - 1]?.createdAt?.toString(), 136 | ), 137 | }, 138 | }; 139 | }, 140 | message: async (parent, { id }, { models }) => { 141 | return await models.Message.findById(id); 142 | }, 143 | }, 144 | 145 | Mutation: { 146 | // combine middlewares 147 | createMessage: combineResolvers( 148 | // resolver middleware 149 | isAuthenticated, 150 | // obican resolver 151 | async (parent, { file }, { models, me }) => { 152 | const fileSaved = await processFile(file); 153 | 154 | // mora create a ne constructor za timestamps 155 | const message = await models.Message.create({ 156 | fileId: fileSaved.id, 157 | userId: me.id, 158 | }); 159 | 160 | pubsub.publish(EVENTS.MESSAGE.CREATED, { 161 | messageCreated: { message }, 162 | }); 163 | 164 | return message; 165 | }, 166 | ), 167 | 168 | deleteMessage: combineResolvers( 169 | isAuthenticated, 170 | isMessageOwner, 171 | async (parent, { messageId }, { models }) => { 172 | const message = await models.Message.findById(messageId); 173 | if (!message) return false; 174 | 175 | let originalMessage = message; 176 | if (message.isReposted) { 177 | originalMessage = await models.Message.findById( 178 | message.repost.originalMessageId, 179 | ); 180 | } 181 | //nadji sve rt, obrisi njih + original 182 | const allRepostsIds = await models.Message.find( 183 | { 184 | 'repost.originalMessageId': originalMessage.id, 185 | }, 186 | '_id', 187 | ); 188 | 189 | // DELETE FILE 190 | const file = await models.File.findById( 191 | originalMessage.fileId, 192 | ); 193 | if (file.path !== 'test.mp3') deleteFile(file.path); 194 | 195 | await models.Message.deleteMany({ 196 | _id: { 197 | $in: [ 198 | ...allRepostsIds.map(i => i._id), 199 | originalMessage.id, 200 | ], 201 | }, 202 | }); 203 | return true; 204 | }, 205 | ), 206 | 207 | likeMessage: combineResolvers( 208 | isAuthenticated, 209 | async (parent, { messageId }, { models, me }) => { 210 | //da li je original 211 | const message = await models.Message.findById(messageId); 212 | if (!message) return false; 213 | 214 | let originalMessage = message; 215 | if (message.isReposted) { 216 | originalMessage = await models.Message.findById( 217 | message.repost.originalMessageId, 218 | ); 219 | } 220 | //nadji sve retvitove te poruke 221 | const allRepostsIds = await models.Message.find( 222 | { 223 | 'repost.originalMessageId': originalMessage.id, 224 | }, 225 | '_id', 226 | ); 227 | await models.Message.updateMany( 228 | { 229 | _id: { 230 | $in: [ 231 | ...allRepostsIds.map(i => i._id), 232 | originalMessage.id, 233 | ], 234 | }, 235 | }, 236 | { $push: { likesIds: me.id } }, 237 | ); 238 | 239 | await publishMessageNotification(originalMessage, me, 'like'); 240 | 241 | return !!originalMessage; 242 | }, 243 | ), 244 | unlikeMessage: combineResolvers( 245 | isAuthenticated, 246 | async (parent, { messageId }, { models, me }) => { 247 | //identicno, samo pull 248 | const message = await models.Message.findById(messageId); 249 | if (!message) return false; 250 | 251 | let originalMessage = message; 252 | if (message.isReposted) { 253 | originalMessage = await models.Message.findById( 254 | message.repost.originalMessageId, 255 | ); 256 | } 257 | const allRepostsIds = await models.Message.find( 258 | { 259 | 'repost.originalMessageId': originalMessage, 260 | }, 261 | '_id', 262 | ); 263 | await models.Message.updateMany( 264 | { 265 | _id: { 266 | $in: [ 267 | ...allRepostsIds.map(i => i._id), 268 | originalMessage.id, 269 | ], 270 | }, 271 | }, 272 | { $pull: { likesIds: me.id } }, 273 | ); 274 | 275 | await publishMessageNotification( 276 | originalMessage, 277 | me, 278 | 'unlike', 279 | ); 280 | 281 | return !!originalMessage; 282 | }, 283 | ), 284 | repostMessage: combineResolvers( 285 | isAuthenticated, 286 | async (parent, { messageId }, { models, me }) => { 287 | const message = await models.Message.findById(messageId); 288 | let originalMessage = message; 289 | 290 | if (message.isReposted) { 291 | //retvitujem retvit 292 | originalMessage = await models.Message.findById( 293 | message.repost.originalMessageId, 294 | ); 295 | } 296 | 297 | const repostedMessage = await models.Message.create({ 298 | fileId: originalMessage.fileId, 299 | userId: originalMessage.userId, 300 | likesIds: originalMessage.likesIds, 301 | isReposted: true, 302 | repost: { 303 | reposterId: ObjectId(me.id), 304 | originalMessageId: originalMessage.id, 305 | }, 306 | }); 307 | 308 | //tu je greska, saljem staru poruku subskripciji 309 | // retvitovana poruka uopste ne postoji u bazi 310 | // pubsub.publish(EVENTS.MESSAGE.CREATED, { 311 | // messageCreated: { message: repostedMessage }, //subs treba da ubaci poruku 312 | // }); 313 | await publishMessageNotification( 314 | originalMessage, 315 | me, 316 | 'repost', 317 | ); 318 | //samo za update rt broja i zeleno 319 | return !!repostedMessage; 320 | }, 321 | ), 322 | unrepostMessage: combineResolvers( 323 | isAuthenticated, 324 | async (parent, { messageId }, { models, me }) => { 325 | const message = await models.Message.findById(messageId); 326 | 327 | //unrepost moj rt tudjeg rta 328 | const myRepost = await models.Message.findOne({ 329 | 'repost.originalMessageId': message.id, 330 | 'repost.reposterId': me.id, 331 | }); 332 | 333 | if (myRepost) { 334 | await publishMessageNotification(message, me, 'unrepost'); 335 | await myRepost.remove(); 336 | return true; 337 | } else { 338 | const originalMessage = await models.Message.findById( 339 | message.repost.originalMessageId, 340 | ); 341 | await publishMessageNotification( 342 | originalMessage, 343 | me, 344 | 'unrepost', 345 | ); 346 | await message.remove(); 347 | return true; 348 | } 349 | }, 350 | ), 351 | }, 352 | 353 | Message: { 354 | user: async (message, args, { loaders }) => { 355 | // loaders iz contexta koji je prosledjen 356 | return await loaders.user.load(message.userId); 357 | }, 358 | file: async (message, args, { loaders }) => { 359 | return await loaders.file.load(message.fileId); 360 | }, 361 | likesCount: async (message, args, { models }) => { 362 | const likedMessage = await models.Message.findById(message.id); 363 | return likedMessage.likesIds?.length || 0; 364 | }, 365 | isLiked: async (message, args, { models, me }) => { 366 | if (!me) return false; 367 | const likedMessage = await models.Message.findById(message.id); 368 | return !!likedMessage.likesIds?.includes(me.id) || false; //ista greska me nije dobar 369 | }, 370 | repostsCount: async (message, args, { models }) => { 371 | let originalMessage = message; 372 | if (message.isReposted) { 373 | //rts 374 | originalMessage = await models.Message.findById( 375 | message.repost.originalMessageId, 376 | ); 377 | } 378 | return await models.Message.find({ 379 | 'repost.originalMessageId': originalMessage.id, 380 | }).countDocuments(); 381 | }, 382 | isRepostedByMe: async (message, args, { models, me }) => { 383 | if (!me) return false; 384 | let originalMessage = message; 385 | if (message.isReposted) { 386 | //rts 387 | //nadji original 388 | originalMessage = await models.Message.findById( 389 | message.repost.originalMessageId, 390 | ); 391 | } 392 | //nadji retvit 393 | const isRepostedByMe = await models.Message.findOne({ 394 | 'repost.originalMessageId': originalMessage.id, 395 | 'repost.reposterId': me.id, 396 | }); 397 | 398 | return !!isRepostedByMe; 399 | }, 400 | repost: async (message, args, { models }) => { 401 | if (!message.isReposted) return null; 402 | 403 | const reposter = await models.User.findById( 404 | message.repost.reposterId, 405 | ); 406 | const originalMessage = await models.Message.findById( 407 | message.repost.originalMessageId, 408 | ); 409 | return { reposter, originalMessage }; 410 | }, 411 | }, 412 | //za nelogovanog ne radi ni timeline ni profile 413 | Subscription: { 414 | messageCreated: { 415 | subscribe: withFilter( 416 | () => pubsub.asyncIterator(EVENTS.MESSAGE.CREATED), 417 | async (payload, { username }, { me }) => { 418 | //console.log(payload); 419 | if (payload.messageCreated.message.isReposted) { 420 | const reposterId = 421 | payload.messageCreated.message.repost.reposterId; 422 | const reposter = await models.User.findById(reposterId); 423 | const followers = await models.User.find({ 424 | followingIds: { $in: [reposterId] }, 425 | }); 426 | const amIFollowingHim = !!followers.find( 427 | u => u.username === me.username, 428 | ); 429 | // username je stranica 430 | const cond1 = !me && !username; // koji nisu logovani i na glavnom timelineu 431 | const cond2 = 432 | !!username && username === reposter.username; //koji su na reposterovom profilu 433 | const cond3 = 434 | !username && 435 | (amIFollowingHim || reposter.username === me.username); //na mom timelineu 436 | const cond4 = username === me.username; //ako sam ja na svom profilu 437 | console.log('repost ', cond1, cond2, cond3, cond4); 438 | 439 | if (cond1 || cond2 || cond3 || cond4) return true; 440 | else return false; 441 | } else { 442 | const userId = payload.messageCreated.message.userId; 443 | const user = await models.User.findById(userId); 444 | const followers = await models.User.find({ 445 | followingIds: { $in: [userId] }, 446 | }); 447 | const amIFollowingHim = !!followers.find( 448 | u => u.username === me.username, 449 | ); 450 | 451 | const cond1 = !me && !username; // koji nisu logovani i na glavnom timelineu 452 | const cond2 = !!username && username === user.username; //koji su na userovom profilu 453 | const cond3 = 454 | !username && 455 | (amIFollowingHim || user.username === me.username); //na mom timelineu 456 | const cond4 = username === me.username; //ako sam ja na svom profilu 457 | console.log('user message ', cond1, cond2, cond3, cond4); 458 | if (cond1 || cond2 || cond3 || cond4) return true; 459 | else return false; 460 | } 461 | }, 462 | ), 463 | }, 464 | }, 465 | }; 466 | -------------------------------------------------------------------------------- /client/src/pages/Profile.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useQuery, useMutation } from '@apollo/react-hooks'; 3 | 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Card from '@material-ui/core/Card'; 6 | import CardActionArea from '@material-ui/core/CardActionArea'; 7 | import CardActions from '@material-ui/core/CardActions'; 8 | import CardContent from '@material-ui/core/CardContent'; 9 | import CardMedia from '@material-ui/core/CardMedia'; 10 | import Button from '@material-ui/core/Button'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import Grid from '@material-ui/core/Grid'; 13 | import Avatar from '@material-ui/core/Avatar'; 14 | import TextField from '@material-ui/core/TextField'; 15 | import Dialog from '@material-ui/core/Dialog'; 16 | import DialogActions from '@material-ui/core/DialogActions'; 17 | import DialogContent from '@material-ui/core/DialogContent'; 18 | import DialogContentText from '@material-ui/core/DialogContentText'; 19 | import DialogTitle from '@material-ui/core/DialogTitle'; 20 | import Tabs from '@material-ui/core/Tabs'; 21 | import Tab from '@material-ui/core/Tab'; 22 | 23 | import Autoplay from '../components/Autoplay/Autoplay'; 24 | import WhoToFollow from '../components/WhoToFollow/WhoToFollow'; 25 | import Messages from '../components/Messages/Messages'; 26 | import UsersTab from '../components/UsersTab/UsersTab'; 27 | import Microphone from '../components/Microphone/Microphone'; 28 | 29 | import withAuthorization from '../session/withAuthorization'; 30 | import { GET_USER, GET_REFETCH_FOLLOWERS } from '../graphql/queries'; 31 | import { 32 | UPDATE_USER, 33 | FOLLOW_USER, 34 | UNFOLLOW_USER, 35 | SET_REFETCH_FOLLOWERS, 36 | } from '../graphql/mutations'; 37 | import Loading from '../components/Loading/Loading'; 38 | import { UPLOADS_IMAGES_FOLDER } from '../constants/paths'; 39 | import * as routes from '../constants/routes'; 40 | 41 | const TabPanel = props => { 42 | const { children, value, index, ...other } = props; 43 | 44 | return ( 45 | 55 | ); 56 | }; 57 | 58 | const useStyles = makeStyles(theme => ({ 59 | card: { width: '100%' }, 60 | media: { 61 | height: 170, 62 | }, 63 | large: { 64 | width: 150, 65 | height: 150, 66 | border: '3px solid', 67 | borderColor: theme.palette.background.paper, 68 | bottom: 0, 69 | position: 'relative', 70 | left: 12, 71 | top: 70, 72 | alignItems: 'center', 73 | }, 74 | content: { 75 | paddingTop: 20, 76 | }, 77 | username: { 78 | fontWeight: 'bold', 79 | }, 80 | numbers: { 81 | textAlign: 'center', 82 | }, 83 | numbersSection: { 84 | paddingTop: 10, 85 | }, 86 | editProfile: { 87 | display: 'flex', 88 | justifyContent: 'flex-end', 89 | }, 90 | input: { 91 | display: 'none', 92 | }, 93 | fileNames: { 94 | display: 'inline-block', 95 | marginLeft: theme.spacing(2), 96 | }, 97 | followsYou: { 98 | marginLeft: theme.spacing(1), 99 | }, 100 | tabs: { 101 | flex: 1, 102 | }, 103 | cardActions: { 104 | padding: 0, 105 | }, 106 | tabsLabel: { 107 | textTransform: 'none', 108 | }, 109 | tabRoot: { 110 | minWidth: 'auto', 111 | }, 112 | })); 113 | 114 | const ProfilePage = ({ match, session, history }) => { 115 | const classes = useStyles(); 116 | 117 | const [open, setOpen] = useState(false); 118 | const [avatar, setAvatar] = useState(null); 119 | const [cover, setCover] = useState(null); 120 | const [name, setName] = useState(''); 121 | const [bio, setBio] = useState(''); 122 | 123 | const [tab, setTab] = React.useState(0); 124 | 125 | const { data, error, loading, refetch } = useQuery(GET_USER, { 126 | variables: { username: match?.params?.username }, 127 | }); 128 | 129 | const { 130 | data: { 131 | refetchFollowers: { signal }, 132 | }, 133 | } = useQuery(GET_REFETCH_FOLLOWERS); 134 | 135 | useEffect(() => { 136 | refetch(); 137 | }, [signal]); 138 | 139 | const [updateUser] = useMutation(UPDATE_USER); 140 | const [followUser] = useMutation(FOLLOW_USER); 141 | const [unfollowUser] = useMutation(UNFOLLOW_USER); 142 | const [setRefetchFollowers] = useMutation(SET_REFETCH_FOLLOWERS); 143 | 144 | if (loading) return ; 145 | // console.log(error, data, session); 146 | 147 | if (!data?.user) { 148 | history.push(routes.HOME); 149 | return null; 150 | } 151 | 152 | const { user } = data; 153 | 154 | const isMyProfile = 155 | session?.me?.username === match?.params?.username; 156 | 157 | const handleTabChange = (event, newTab) => { 158 | setTab(newTab); 159 | }; 160 | 161 | const handleFollow = async () => { 162 | await followUser({ variables: { username: user.username } }); 163 | setRefetchFollowers(); 164 | }; 165 | const handleUnfollow = async () => { 166 | await unfollowUser({ variables: { username: user.username } }); 167 | setRefetchFollowers(); 168 | }; 169 | 170 | const handleInput = event => { 171 | const { name, value } = event.target; 172 | if (name === 'name') setName(value); 173 | if (name === 'bio') setBio(value); 174 | }; 175 | 176 | const handleAvatar = event => { 177 | setAvatar(event.target.files[0]); 178 | }; 179 | const handleCover = event => { 180 | setCover(event.target.files[0]); 181 | console.log(event.target.files); 182 | }; 183 | 184 | const handleOpen = () => { 185 | setOpen(true); 186 | setName(user.name); 187 | setBio(user.bio); 188 | }; 189 | 190 | const handleClose = () => { 191 | setOpen(false); 192 | }; 193 | 194 | const handleSave = async () => { 195 | const variables = { 196 | avatar, 197 | cover, 198 | name, 199 | bio, 200 | }; 201 | 202 | for (let prop in variables) 203 | if (!variables[prop]) delete variables[prop]; 204 | 205 | const { data } = await updateUser({ variables }); 206 | // console.log(data); 207 | refetch(); 208 | 209 | setOpen(false); 210 | }; 211 | 212 | return ( 213 | <> 214 | 220 | 230 | 231 | 232 | 233 | {session?.me && ( 234 | 235 | 239 | 240 | )} 241 | 242 | 252 | 253 | 254 | 258 | 262 | 263 | 264 |
265 | {isMyProfile ? ( 266 | 273 | ) : ( 274 | <> 275 | {user.isFollowHim ? ( 276 | 283 | ) : ( 284 | 291 | )} 292 | 293 | )} 294 |
295 | 300 | {user.name} 301 | 302 | {user.isFollowsMe && ( 303 | 310 | follows you 311 | 312 | )} 313 | 319 | @{user.username} 320 | 321 | 322 | {user.bio} 323 | 324 |
325 | 326 | 334 | 338 | 343 | {user.messagesCount} 344 | 345 | 350 | Messages 351 | 352 | 353 | } 354 | /> 355 | 359 | 364 | {user.followersCount} 365 | 366 | 371 | Followers 372 | 373 | 374 | } 375 | /> 376 | 380 | 385 | {user.followingCount} 386 | 387 | 392 | Following 393 | 394 | 395 | } 396 | /> 397 | 398 | 399 |
400 |
401 | 402 | 403 | 404 | 409 | 410 | 411 | 417 | 418 | 419 | 425 | 426 | 427 | 432 | 433 | Edit profile 434 | 435 | 436 | 437 | 438 | 445 | 454 | 455 | {avatar && avatar.name} 456 | 457 | 458 | 459 | 466 | 475 | 476 | {cover && cover.name} 477 | 478 | 479 | 480 | 487 | 495 | 496 | 497 | 500 | 503 | 504 | 505 |
506 |
507 | {session?.me && } 508 | 509 | ); 510 | }; 511 | 512 | // export default withAuthorization(session => session && session.me)( 513 | // AccountPage, 514 | // ); 515 | export default ProfilePage; 516 | --------------------------------------------------------------------------------