├── 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 |
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 | 
58 |
59 | 
60 |
61 | 
62 |
63 | 
64 |
65 | 
66 |
67 | 
68 |
69 | 
70 |
71 | 
72 |
73 | 
74 |
75 | 
76 |
77 | 
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 |
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 |
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 |
52 | {value === index && {children}
}
53 |
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 | }
263 | onClick={handleMenuOpen}
264 | >
265 | Theme
266 |
267 |
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 |
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 |
53 | {value === index && {children}
}
54 |
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 |
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 |
--------------------------------------------------------------------------------