├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
├── index.svg
└── manifest.json
├── src
├── chatkit.js
├── components
│ ├── CreateMessageForm
│ │ ├── index.js
│ │ └── index.module.css
│ ├── CreateRoomForm
│ │ ├── index.js
│ │ └── index.module.css
│ ├── FileInput
│ │ ├── index.js
│ │ └── index.module.css
│ ├── JoinRoomScreen
│ │ ├── index.js
│ │ └── index.module.css
│ ├── Message
│ │ ├── index.js
│ │ └── index.module.css
│ ├── MessageList
│ │ ├── index.js
│ │ └── index.module.css
│ ├── RoomHeader
│ │ ├── index.js
│ │ └── index.module.css
│ ├── RoomList
│ │ ├── index.js
│ │ └── index.module.css
│ ├── TypingIndicator
│ │ ├── index.js
│ │ └── index.module.css
│ ├── UserHeader
│ │ ├── index.js
│ │ └── index.module.css
│ ├── UserList
│ │ ├── index.js
│ │ └── index.module.css
│ └── WelcomeScreen
│ │ ├── index.js
│ │ └── index.module.css
├── index.css
└── index.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | client/static/index.js
4 | .DS_Store
5 | .idea/
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "9"
4 |
5 | script: yarn build
6 |
7 | deploy:
8 | local_dir: build
9 | provider: pages
10 | skip_cleanup: true
11 | github_token: $GITHUB_TOKEN
12 | on:
13 | branch: master
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018 Pusher, Ltd (https://pusher.com)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | of the Software, and to permit persons to whom the Software is furnished to do
8 | so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Slack Clone
2 |
3 | [![Star on GitHub][github-star-badge]][github-star]
4 | [![Tweet][twitter-badge]][twitter]
5 | [![Build Status][travis-badge]][travis]
6 |
7 | > Slack clone powered by [Chatkit](https://pusher.com/chatkit). See it in action here https://pusher.github.io/react-slack-clone
8 |
9 | 
10 |
11 | This is a static, single page web app bootstrapped with [create-react-app](https://github.com/facebookincubator/create-react-app) for ease of setup, distribution and development. It is a thin UI wrapper around the [pusher-chatkit-client](https://github.com/pusher/chatkit-client-js) library to demonstrate how different features can work together to form a compelling real-time chat client with various potential product applications.
12 |
13 | ## Features
14 |
15 | The Chatkit SDK allows you to implement features you would expect from a chat client. These include:
16 |
17 | * 📝 Public and private chat rooms
18 | * 📡 Realtime sending and receiving of messages
19 | * 📦 Rich media attachments (drag and drop)
20 | * 💬 Typing and presence indicators
21 | * 📚 Read message cursors
22 |
23 | Want to get involved? We have a bunch of [beginner-friendly GitHub issues](https://github.com/pusher/react-slack-clone/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
24 |
25 | ## Components
26 |
27 | The demo attempts to be feature complete according to documentation [here](https://docs.pusher.com/chatkit/reference/javascript). Feature requests should be made via issues or pull requests to this repository.
28 |
29 | * CreateMessageForm - to send a message with a textual body and trigger typing indicators.
30 | * CreateRoomForm - to create a new room and join it upon creation.
31 | * FileInput - to send a message with a rich media attachment.
32 | * Message - to render out a message that potentially includes an attachment.
33 | * MessageList - to render a list of messages from a key value store.
34 | * RoomHeader - to display useful information about a given room.
35 | * RoomList - to render a list of rooms which can be subscribed to by the current user.
36 | * TypingIndicator - to signify to the user that another user is typing in a given room.
37 | * UserHeader - to display useful information about a given user.
38 |
39 | ## Usage
40 |
41 | To run the application locally; clone the repo, install dependencies and run the app.
42 |
43 | ```
44 | $ git clone https://github.com/pusher/react-slack-clone
45 | $ cd react-slack-clone
46 | $ yarn && yarn start
47 | ```
48 |
49 | The app starts in development mode and opens a browser window on `http://localhost:3000`. The project rebuilds and the browser reloads automatically when source files are changed. Any build or runtime errors are propagated and displayed in the browser.
50 |
51 | The app depends on GitHub authentication and a user creation endpoint that is hosted at https://chatkit-demo-server.herokuapp.com. The endpoints are `/auth` and `/token`.
52 |
53 | [github-star-badge]: https://img.shields.io/github/stars/pusher/react-slack-clone.svg?style=social
54 | [github-star]: https://github.com/pusher/react-slack-clone/stargazers
55 | [twitter-badge]: https://img.shields.io/twitter/url/https/github.com/kentcdodds/react-testing-library.svg?style=social
56 | [twitter]: https://twitter.com/intent/tweet?text=Check%20out%20this%20Slack%20clone%20using%20@pusher%20Chatkit%20%F0%9F%91%89https://github.com/pusher/react-slack-clone
57 | [travis-badge]: https://travis-ci.org/pusher/react-slack-clone.svg?branch=master
58 | [travis]: https://travis-ci.org/pusher/react-slack-clone
59 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chatkit-demo",
3 | "version": "2.1.0",
4 | "homepage": "https://pusher.github.io/react-slack-clone",
5 | "dependencies": {
6 | "@pusher/chatkit-client": "^1.3.0",
7 | "react": "^16.2.0",
8 | "react-dom": "^16.2.0",
9 | "react-linkify": "^0.2.2",
10 | "react-scripts": "2.0.0",
11 | "vuid": "^1.0.0"
12 | },
13 | "scripts": {
14 | "start": "react-scripts start",
15 | "build": "react-scripts build",
16 | "test": "react-scripts test --env=jsdom",
17 | "eject": "react-scripts eject"
18 | },
19 | "browserslist": {
20 | "development": [
21 | "last 2 chrome versions",
22 | "last 2 firefox versions",
23 | "last 2 edge versions"
24 | ],
25 | "production": [
26 | ">1%",
27 | "last 4 versions",
28 | "Firefox ESR",
29 | "not ie < 11"
30 | ]
31 | },
32 | "prettier": {
33 | "trailingComma": "es5",
34 | "tabWidth": 2,
35 | "semi": false,
36 | "singleQuote": true
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lukejacksonn/react-slack-clone/fd62288d063c811c6e609ac863dd4fd8ab321961/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
15 |
24 | Pusher | Chatkit Demo
25 |
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/public/index.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Pusher Chatkit Demo",
3 | "name": "Pusher Chatkit SDK Demo",
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 |
--------------------------------------------------------------------------------
/src/chatkit.js:
--------------------------------------------------------------------------------
1 | import Chatkit from '@pusher/chatkit-client'
2 |
3 | const credentials = {
4 | url: (id, token) =>
5 | `https://chatkit-demo-server.herokuapp.com/token?user=${id}&token=${token}`,
6 | instanceLocator: 'v1:us1:05f46048-3763-4482-9cfe-51ff327c3f29',
7 | }
8 |
9 | const { instanceLocator, url } = credentials
10 | export default ({ state, actions }, { id, token }) =>
11 | new Chatkit.ChatManager({
12 | tokenProvider: new Chatkit.TokenProvider({ url: url(id, token) }),
13 | instanceLocator,
14 | userId: id,
15 | })
16 | .connect({
17 | onUserStartedTyping: actions.isTyping,
18 | onUserStoppedTyping: actions.notTyping,
19 | onAddedToRoom: actions.subscribeToRoom,
20 | onRemovedFromRoom: actions.removeRoom,
21 | onPresenceChanged: actions.setUserPresence,
22 | })
23 | .then(user => {
24 | // Subscribe to all rooms the user is a member of
25 | Promise.all(
26 | user.rooms.map(room =>
27 | user.subscribeToRoom({
28 | roomId: room.id,
29 | hooks: { onMessage: actions.addMessage },
30 | })
31 | )
32 | ).then(rooms => {
33 | actions.setUser(user)
34 | // Join the first room in the users room list
35 | user.rooms.length > 0 && actions.joinRoom(user.rooms[0])
36 | })
37 | })
38 | .catch(error => console.log('Error on connection', error))
39 |
--------------------------------------------------------------------------------
/src/components/CreateMessageForm/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 | import { FileInput } from '../FileInput'
4 |
5 | export const CreateMessageForm = ({
6 | state: { user = {}, room = {}, message = '' },
7 | actions: { runCommand },
8 | }) =>
9 | room.id ? (
10 |
42 | ) : null
43 |
--------------------------------------------------------------------------------
/src/components/CreateMessageForm/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | flex: none;
3 | position: relative;
4 | display: flex;
5 | align-items: center;
6 | border-top: 1px solid #e0e0e0;
7 | height: 3.6rem;
8 | display: flex;
9 | width: 100%;
10 | }
11 |
12 | .component input:first-child {
13 | flex: 1 1 100%;
14 | padding: 1rem;
15 | border: 0;
16 | font-size: 1rem;
17 | }
18 |
19 | .component button {
20 | position: relative;
21 | border: 0;
22 | background: #fff;
23 | padding: 0;
24 | cursor: pointer;
25 | }
26 |
27 | .component > * {
28 | margin-right: 0.62rem;
29 | }
30 |
31 | .component svg {
32 | width: 2rem;
33 | height: 2rem;
34 | fill: #006eff;
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/CreateRoomForm/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const CreateRoomForm = ({ submit }) => (
5 |
29 | )
30 |
--------------------------------------------------------------------------------
/src/components/CreateRoomForm/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | flex: none;
3 | position: relative;
4 | display: flex;
5 | align-items: center;
6 | border-top: 1px solid #e0e0e0;
7 | height: 3.6rem;
8 | display: flex;
9 | width: 100%;
10 | margin-top: auto;
11 | }
12 |
13 | .component > input {
14 | flex: 1 1 100%;
15 | padding: 1rem;
16 | border: 0;
17 | font-size: 1rem;
18 | }
19 |
20 | .component > button > input {
21 | flex: none;
22 | position: absolute;
23 | top: 0;
24 | left: 0;
25 | bottom: 0;
26 | right: 0;
27 | width: 100%;
28 | height: 100%;
29 | opacity: 0;
30 | z-index: 1;
31 | }
32 |
33 | .component > * {
34 | margin-right: 0.38rem;
35 | }
36 |
37 | .component button {
38 | position: relative;
39 | border: 0;
40 | padding: 0;
41 | background: transparent;
42 | cursor: pointer;
43 | }
44 |
45 | .component svg {
46 | width: 2rem;
47 | height: 2rem;
48 | fill: #006eff;
49 | }
50 |
51 | .component input {
52 | cursor: pointer;
53 | }
54 |
55 | .component input[type='checkbox']:not(:checked) + svg {
56 | opacity: 0.38;
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/FileInput/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const FileInput = ({ state: { user, message, room } }) =>
5 | room.id ? (
6 |
7 |
8 |
9 |
10 | {
14 | const file = e.target.files[0]
15 | file &&
16 | user.sendMessage({
17 | text: message || file.name,
18 | roomId: room.id,
19 | attachment: {
20 | name: file.name.replace(/[^A-Za-z0-9._-]/g, '--'),
21 | file,
22 | },
23 | })
24 | }}
25 | />
26 |
27 | ) : null
28 |
--------------------------------------------------------------------------------
/src/components/FileInput/index.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | }
4 |
5 | .component {
6 | flex: none;
7 | position: absolute;
8 | top: 0;
9 | left: 0;
10 | bottom: 0;
11 | right: 0;
12 | width: 100%;
13 | height: 100%;
14 | opacity: 0;
15 | cursor: pointer;
16 | }
17 |
18 | .component::-webkit-file-upload-button {
19 | cursor: pointer;
20 | }
--------------------------------------------------------------------------------
/src/components/JoinRoomScreen/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const JoinRoomScreen = () => (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Join a room, create a room or click on a user avatar and start a
18 | conversation.
19 |
20 |
21 |
22 | )
23 |
--------------------------------------------------------------------------------
/src/components/JoinRoomScreen/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | margin: auto;
3 | text-align: center;
4 | color: #b6b6b6;
5 | }
6 |
7 | .component > * + * {
8 | margin-top: 1rem;
9 | }
10 |
11 | .component > span {
12 | font-size: 3rem;
13 | }
14 |
15 | .component > p {
16 | font-size: 1rem;
17 | line-height: 150%;
18 | max-width: 40ex;
19 | }
20 |
21 | .component svg {
22 | width: 100%;
23 | height: 6rem;
24 | opacity: 0.1;
25 | animation: pulse 2s infinite;
26 | }
27 |
28 | @keyframes pulse {
29 | from {
30 | transform: scale(1);
31 | }
32 | 50% {
33 | transform: scale(0.9);
34 | }
35 | to {
36 | transform: scale(1);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/Message/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 | import Linkify from 'react-linkify'
4 |
5 | const time = string => {
6 | const date = new Date(string)
7 | const minutes = date.getMinutes()
8 | return `${date.getHours()}:${minutes < 10 ? '0' + minutes : minutes}`
9 | }
10 |
11 | class Attachment extends React.Component {
12 | componentDidMount() {
13 | if (this.props.link) this.setState({ src: this.props.link });
14 | }
15 | render() {
16 | return this.state
17 | ? {
18 | image: (
19 |
20 | ),
21 | video: ,
22 | audio: ,
23 | file: (
24 |
25 | Download File
26 |
27 | ),
28 | }[this.props.type]
29 | : null
30 | }
31 | }
32 |
33 | export const Message = ({ user, createConvo }) => message =>
34 | message.sender ? (
35 |
36 | createConvo({ user: message.sender })}
38 | src={message.sender.avatarURL}
39 | alt={message.sender.name}
40 | />
41 |
42 |
{`${message.sender.name} | ${time(message.createdAt)}`}
51 |
52 | {message.text}
53 |
54 | {message.attachment ? (
55 |
60 | ) : null}
61 |
62 |
63 | ) : null
64 |
--------------------------------------------------------------------------------
/src/components/Message/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | position: relative;
3 | display: flex;
4 | flex: none;
5 | font-size: 16px;
6 | padding-top: 1rem;
7 | }
8 |
9 | .component:not(:last-child) {
10 | border-top: 1px solid #f2f2f2;
11 | margin-top: 1rem;
12 | }
13 |
14 | .component > img {
15 | flex: none;
16 | width: 2.4rem;
17 | height: 2.4rem;
18 | border-radius: 0.38rem;
19 | }
20 |
21 | .component > div > img,
22 | .component > div > video,
23 | .component > div > audio {
24 | width: 30rem;
25 | max-width: 100%;
26 | height: auto;
27 | border: 1px solid rgba(0, 0, 0, 0.1);
28 | margin-top: 1rem;
29 | min-width: 0;
30 | }
31 |
32 | .component > * + * {
33 | margin-left: 0.38rem;
34 | }
35 |
36 | .component > div {
37 | margin-left: 1rem;
38 | }
39 |
40 | .component > div > p,
41 | .component > div > a {
42 | padding-top: 0.5rem;
43 | max-width: 100%;
44 | word-break: break-word;
45 | color: rgba(0, 0, 0, 0.8);
46 | line-height: 150%;
47 | max-width: 100ex;
48 | }
49 |
50 | .component > div a {
51 | color: #006eff;
52 | }
53 |
54 | .component > div > span {
55 | position: relative;
56 | display: flex;
57 | align-items: center;
58 | font-size: 0.62rem;
59 | color: #b6b6b6;
60 | margin: 0;
61 | }
62 |
63 | .online::before {
64 | display: block;
65 | content: '';
66 | width: 0.5rem;
67 | height: 0.5rem;
68 | background: #42b72a;
69 | border-radius: 50%;
70 | margin-right: 0.38rem;
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/MessageList/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 | import { Message } from '../Message'
4 |
5 | const emptyList = (
6 |
7 |
8 | 📝
9 |
10 |
No Messages Yet
11 |
Be the first to post in this room or invite someone to join the room
12 |
13 | )
14 |
15 | export const MessageList = ({ messages = {}, user, createConvo }) => (
16 |
17 | {Object.keys(messages).length > 0 ? (
18 |
19 | {Object.keys(messages)
20 | .reverse()
21 | .map(k => Message({ user, createConvo })(messages[k]))}
22 |
23 | ) : (
24 | emptyList
25 | )}
26 |
27 | )
28 |
--------------------------------------------------------------------------------
/src/components/MessageList/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | flex: 1 1 100%;
3 | padding: 1rem;
4 | border-bottom: 2rem solid #fafafa;
5 | margin: 0;
6 | display: flex;
7 | overflow-y: auto;
8 | -webkit-overflow-scrolling: touch;
9 | }
10 |
11 | .component > wrapper- {
12 | width: 100%;
13 | margin-top: auto;
14 | display: flex;
15 | flex-direction: column-reverse;
16 | }
17 |
18 | .empty {
19 | margin: auto;
20 | text-align: center;
21 | color: #b6b6b6;
22 | }
23 |
24 | .empty > * + * {
25 | margin-top: 1rem;
26 | }
27 |
28 | .empty > span {
29 | font-size: 3rem;
30 | }
31 |
32 | .empty > p {
33 | font-size: 1rem;
34 | line-height: 150%;
35 | max-width: 40ex;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/RoomHeader/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const RoomHeader = ({
5 | state: { room, user, sidebarOpen, userListOpen },
6 | actions: { setSidebar, setUserList },
7 | }) => (
8 |
24 | )
25 |
--------------------------------------------------------------------------------
/src/components/RoomHeader/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | border-bottom: 1px solid #e0e0e0;
3 | z-index: 1;
4 | flex: none;
5 | display: flex;
6 | align-items: center;
7 | padding: 0.62rem;
8 | height: 4.8rem;
9 | }
10 |
11 | .component button {
12 | background: transparent;
13 | border: 0;
14 | padding: 0;
15 | }
16 |
17 | .component h1 {
18 | font-size: 1.38rem;
19 | color: rgba(0, 0, 0, 0.62);
20 | margin: auto;
21 | }
22 |
23 | .component span {
24 | color: rgba(0, 0, 0, 0.62);
25 | }
26 |
27 | .component div {
28 | display: flex;
29 | align-items: center;
30 | border: 1px solid rgba(0, 0, 0, 0.1);
31 | align-self: center;
32 | padding: 0.62rem;
33 | border-radius: 0.5rem;
34 | cursor: pointer;
35 | user-select: none;
36 | }
37 |
38 | .component div > * + * {
39 | margin-left: 0.38rem;
40 | }
41 |
42 | .component svg {
43 | width: 1.8rem;
44 | height: 1.8rem;
45 | fill: rgba(0, 0, 0, 0.38);
46 | }
47 |
48 | @media (min-width: 640px) {
49 | .component button {
50 | display: none;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/RoomList/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 | import { dots } from '../TypingIndicator/index.module.css'
4 |
5 | const Icon = id => (
6 |
7 |
8 |
9 | )
10 |
11 | const unreads = (user, room, messages = {}) => {
12 | const read = user.readCursor({ roomId: room.id })
13 | return (
14 | (read && Object.keys(messages).filter(x => x > read.position).length) ||
15 | undefined
16 | )
17 | }
18 |
19 | const priority = (user, room, messages = {}) => {
20 | const unreadMessages = unreads(user, room, messages) || 0
21 | const lastMessage = Object.keys(messages).pop() || 0
22 | return (10 * unreadMessages + parseInt(lastMessage)) * -1
23 | }
24 |
25 | export const RoomList = ({
26 | rooms = [],
27 | user,
28 | messages,
29 | current,
30 | typing,
31 | actions,
32 | }) => (
33 |
34 | {rooms.map(room => {
35 | const messageKeys = Object.keys(messages[room.id] || {})
36 | const latestMessage =
37 | messageKeys.length > 0 && messages[room.id][messageKeys.pop()]
38 | const firstUser = room.users.find(x => x.id !== user.id)
39 | const order = priority(user, room, messages[room.id])
40 | const unreadCount = unreads(user, room, messages[room.id])
41 | return (
42 | actions.joinRoom(room)}
46 | style={{ order }}
47 | >
48 | {room.name.match(user.id) && firstUser ? (
49 |
50 | ) : (
51 | Icon(room.isPrivate ? 'lock' : 'public')
52 | )}
53 |
54 | {room.name.replace(user.id, '')}
55 | {latestMessage && latestMessage.text}
56 |
57 | {room.id !== current.id && unreadCount ? (
58 | {unreadCount}
59 | ) : Object.keys(typing[room.id] || {}).length > 0 ? (
60 |
61 | {[0, 1, 2].map(x => (
62 |
63 | ))}
64 |
65 | ) : null}
66 |
67 | )
68 | })}
69 |
70 | )
71 |
--------------------------------------------------------------------------------
/src/components/RoomList/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | padding: 0;
3 | margin: 0;
4 | overflow-y: auto;
5 | -webkit-overflow-scrolling: touch;
6 | display: flex;
7 | flex-direction: column;
8 | height: 100%;
9 | }
10 |
11 | .component li {
12 | flex: none;
13 | display: flex;
14 | align-items: center;
15 | padding: 1rem;
16 | list-style: none;
17 | cursor: pointer;
18 | font-weight: bold;
19 | user-select: none;
20 | color: rgba(0, 0, 0, 0.38);
21 | border-bottom: 1px solid rgba(0, 0, 0, 0.038);
22 | }
23 |
24 | .component li:hover {
25 | background: rgba(0, 0, 0, 0.015);
26 | color: rgba(0, 0, 0, 0.5);
27 | }
28 |
29 | .component li[disabled] {
30 | background: rgba(0, 0, 0, 0.05);
31 | opacity: 1;
32 | color: rgba(0, 0, 0, 0.62);
33 | }
34 |
35 | .component span {
36 | display: block;
37 | white-space: nowrap;
38 | }
39 |
40 | .component col- {
41 | overflow: hidden;
42 | }
43 |
44 | .component label {
45 | display: none;
46 | }
47 |
48 | .component label:not(:empty) {
49 | flex: none;
50 | display: flex;
51 | align-items: center;
52 | justify-content: center;
53 | width: 1.62rem;
54 | height: 1.62rem;
55 | padding: 0;
56 | border-radius: 0.38rem;
57 | font-size: 1rem;
58 | background: rgba(0, 0, 0, 0.2);
59 | color: #fff;
60 | margin-left: 1rem;
61 | }
62 |
63 | .component svg,
64 | .component img {
65 | width: 2.4rem;
66 | height: 2.4rem;
67 | fill: rgba(0, 0, 0, 0.2);
68 | margin-right: 1rem;
69 | border-radius: 50%;
70 | }
71 |
72 | .component h2 {
73 | font-size: 1rem;
74 | padding: 1rem;
75 | background: #fcfcfc;
76 | color: #b6b6b6;
77 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
78 | }
79 |
80 | .component h2:not(:first-child) {
81 | border-top: 1px solid rgba(0, 0, 0, 0.1);
82 | }
83 |
84 | .component span:not(:empty) {
85 | display: block;
86 | font-weight: normal;
87 | font-size: 0.8rem;
88 | margin-top: 0.2rem;
89 | white-space: nowrap;
90 | overflow: hidden;
91 | text-overflow: ellipsis;
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/TypingIndicator/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | const dots = (
5 |
6 | {[0, 1, 2].map(x => (
7 |
8 | ))}
9 |
10 | )
11 |
12 | export const TypingIndicator = ({ typing = {} }) =>
13 | Object.keys(typing).length ? (
14 |
15 |
{dots}
16 |
{`${Object.keys(typing)
17 | .slice(0, 2)
18 | .join(' and ')} is typing`}
19 |
20 | ) : null
21 |
--------------------------------------------------------------------------------
/src/components/TypingIndicator/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | position: absolute;
3 | display: flex;
4 | align-items: center;
5 | bottom: 3.6rem;
6 | height: 2rem;
7 | font-size: 0.62rem;
8 | color: rgba(0, 0, 0, 0.38);
9 | padding: 0 0.62rem;
10 | border-top: 1px solid #f2f2f2;
11 | width: 100%;
12 | }
13 |
14 | .dots {
15 | display: flex;
16 | align-items: center;
17 | width: 1rem;
18 | height: 1rem;
19 | margin-right: 0.62rem;
20 | }
21 |
22 | .dots > * + * {
23 | margin-left: 0.2em;
24 | }
25 |
26 | .dots > div {
27 | width: 0.5em;
28 | height: 0.5em;
29 | border-radius: 50%;
30 | background: rgba(158, 158, 158, 0.7);
31 | transform-origin: 50% 50%;
32 | animation: ball-beat 1.1s 0s infinite cubic-bezier(0.445, 0.05, 0.55, 0.95);
33 | }
34 |
35 | .dots div:nth-child(2) {
36 | animation-delay: 0.3s !important;
37 | }
38 |
39 | .dots div:nth-child(3) {
40 | animation-delay: 0.6s !important;
41 | }
42 |
43 | @keyframes ball-beat {
44 | 0% {
45 | opacity: 0.7;
46 | }
47 | 33.33% {
48 | opacity: 0.55;
49 | }
50 | 66.67% {
51 | opacity: 0.4;
52 | }
53 | 100% {
54 | opacity: 1;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/UserHeader/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | const placeholder =
5 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
6 |
7 | export const UserHeader = ({ user = {} }) => (
8 |
15 | )
16 |
--------------------------------------------------------------------------------
/src/components/UserHeader/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | border-bottom: 1px solid #e0e0e0;
3 | z-index: 1;
4 | flex: none;
5 | display: flex;
6 | align-items: center;
7 | padding: 1rem;
8 | height: 4.8rem;
9 | }
10 |
11 | .component > * + * {
12 | margin-left: 1rem;
13 | }
14 |
15 | .component > img {
16 | width: 2.4rem;
17 | height: 2.4rem;
18 | border-radius: 0.38rem;
19 | background: #e0e0e0;
20 | }
21 |
22 | .component h3 {
23 | color: rgba(0, 0, 0, 0.38);
24 | }
25 |
26 | .component h3:empty {
27 | display: block;
28 | background: #f2f2f2;
29 | width: 10rem;
30 | height: 1rem;
31 | animation: pulse 1s infinite;
32 | }
33 |
34 | .component h5:empty {
35 | display: block;
36 | background: #f2f2f2;
37 | height: 0.5rem;
38 | animation: pulse 1s infinite;
39 | width: 6rem;
40 | line-height: 100%;
41 | margin-top: 0.38rem;
42 | }
43 |
44 | .component h5 {
45 | font-size: 0.8rem;
46 | margin: 0;
47 | margin-top: 0.2rem;
48 | font-weight: normal;
49 | color: rgba(0, 0, 0, 0.38);
50 | }
51 |
52 | @keyframes pulse {
53 | 0% {
54 | opacity: 0.5;
55 | }
56 | 50% {
57 | opacity: 1;
58 | }
59 | 100% {
60 | opacity: 0.5;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/UserList/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const UserList = ({ room, current, createConvo, removeUser }) => (
5 |
27 | )
28 |
--------------------------------------------------------------------------------
/src/components/UserList/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | display: flex;
3 | flex-direction: column;
4 | list-style: none;
5 | background: #fff;
6 | margin: 0;
7 | padding: 0;
8 | overflow-y: auto;
9 | -webkit-overflow-scrolling: touch;
10 | width: 24rem;
11 | border-left: 1px solid rgba(0, 0, 0, 0.1);
12 | }
13 |
14 | .component > li {
15 | flex: none;
16 | display: flex;
17 | align-items: center;
18 | color: rgba(0, 0, 0, 0.38);
19 | font-weight: bold;
20 | font-size: 1rem;
21 | padding: 1rem;
22 | cursor: pointer;
23 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
24 | }
25 |
26 | .component .hint {
27 | border: 0;
28 | }
29 |
30 | .component .hint > div {
31 | text-align: center;
32 | font-size: .9rem;
33 | width: 100%;
34 | font-weight: 400;
35 | }
36 |
37 | .component .hint > div > span {
38 | background: #F4F5F7;
39 | border-radius: 3px;
40 | padding: 1px 3px;
41 | display: inline-block;
42 | box-sizing: border-box;
43 | }
44 |
45 | .component .hint:hover {
46 | background: transparent;
47 | cursor: default;
48 | }
49 |
50 | .component > li:hover {
51 | background: rgba(0, 0, 0, 0.015);
52 | }
53 |
54 | .component li > img {
55 | width: 1.38rem;
56 | height: 1.38rem;
57 | border-radius: 0.1rem;
58 | margin-right: 0.62rem;
59 | }
60 |
61 | .online::after {
62 | display: block;
63 | content: '';
64 | width: 0.62rem;
65 | height: 0.62rem;
66 | background: #42b72a;
67 | border-radius: 50%;
68 | margin-left: auto;
69 | }
70 |
71 | @media (max-width: 1000px) {
72 | .component {
73 | position: absolute;
74 | top: 0;
75 | right: 0;
76 | width: 18rem;
77 | box-shadow: 0 0.38rem 0.62rem rgba(0, 0, 0, 0.1);
78 | max-height: 62vh;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/WelcomeScreen/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import style from './index.module.css'
3 |
4 | export const WelcomeScreen = () => (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Authenticating, subscribing to rooms
18 |
19 | and fetching messages.
20 |
21 |
22 |
23 | )
24 |
--------------------------------------------------------------------------------
/src/components/WelcomeScreen/index.module.css:
--------------------------------------------------------------------------------
1 | .component {
2 | margin: auto;
3 | text-align: center;
4 | color: #b6b6b6;
5 | }
6 |
7 | .component > * + * {
8 | margin-top: 1rem;
9 | }
10 |
11 | .component > span {
12 | font-size: 3rem;
13 | }
14 |
15 | .component > p {
16 | font-size: 1rem;
17 | line-height: 150%;
18 | max-width: 40ex;
19 | }
20 |
21 | .component svg {
22 | width: 100%;
23 | height: 6rem;
24 | opacity: 0.1;
25 | animation: pulse 2s infinite;
26 | }
27 |
28 | @keyframes pulse {
29 | from {
30 | transform: scale(1);
31 | }
32 | 50% {
33 | transform: scale(0.9);
34 | }
35 | to {
36 | transform: scale(1);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | -webkit-font-smoothing: antialiased;
4 | -moz-osx-font-smoothing: grayscale;
5 | }
6 |
7 | body {
8 | display: flex;
9 | height: 100vh;
10 | font-size: 16px;
11 | font-family: sans-serif;
12 | background: #f2f2f2;
13 | margin: 0;
14 | }
15 |
16 | h1,
17 | h2,
18 | h3,
19 | h4,
20 | p {
21 | margin: 0;
22 | }
23 |
24 | button,
25 | input {
26 | outline: none;
27 | min-width: 0;
28 | flex: none;
29 | }
30 |
31 | img,
32 | svg {
33 | display: block;
34 | flex: none;
35 | }
36 |
37 | [disabled] {
38 | opacity: 0.5;
39 | }
40 |
41 | #root {
42 | width: 100%;
43 | }
44 |
45 | main {
46 | display: flex;
47 | margin: auto;
48 | width: 100%;
49 | height: 100%;
50 | background: #fff;
51 | overflow: hidden;
52 | }
53 |
54 | aside {
55 | flex: none;
56 | width: 320px;
57 | display: flex;
58 | flex-direction: column;
59 | border-right: 1px solid rgba(0, 0, 0, 0.1);
60 | background: #fcfcfc;
61 | z-index: 2;
62 | transform: rotateY(360deg);
63 | }
64 |
65 | section {
66 | flex: 1 1 100%;
67 | width: 100%;
68 | display: flex;
69 | flex-direction: column;
70 | position: relative;
71 | }
72 |
73 | row- {
74 | flex: 1 1 100%;
75 | width: 100%;
76 | display: flex;
77 | position: relative;
78 | overflow: hidden;
79 | }
80 |
81 | col- {
82 | flex: 1 1 100%;
83 | width: 100%;
84 | display: flex;
85 | flex-direction: column;
86 | position: relative;
87 | }
88 |
89 | section:not(.dragging) > input[type='file'] {
90 | transform: translate(100%);
91 | }
92 |
93 | @media (max-width: 640px) {
94 | aside {
95 | position: absolute;
96 | left: 0;
97 | top: 4.8rem;
98 | bottom: 0;
99 | transform: translateX(-100%);
100 | transition: transform 0.2s ease-out;
101 | box-shadow: 0 0 0.38rem rgba(0, 0, 0, 0.1);
102 | }
103 | aside[data-open='true'] {
104 | transform: translateX(0);
105 | }
106 | aside[data-open='true'] + section > *:not(header) {
107 | opacity: 0.2;
108 | transition: opacity 0.4s;
109 | overflow: hidden;
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { version } from '../package.json'
4 | import './index.css'
5 |
6 | import { UserHeader } from './components/UserHeader'
7 | import { UserList } from './components/UserList'
8 | import { MessageList } from './components/MessageList'
9 | import { TypingIndicator } from './components/TypingIndicator'
10 | import { CreateMessageForm } from './components/CreateMessageForm'
11 | import { RoomList } from './components/RoomList'
12 | import { RoomHeader } from './components/RoomHeader'
13 | import { CreateRoomForm } from './components/CreateRoomForm'
14 | import { WelcomeScreen } from './components/WelcomeScreen'
15 | import { JoinRoomScreen } from './components/JoinRoomScreen'
16 |
17 | import ChatManager from './chatkit'
18 |
19 | // --------------------------------------
20 | // Application
21 | // --------------------------------------
22 |
23 | class View extends React.Component {
24 | state = {
25 | user: {},
26 | room: {},
27 | messages: {},
28 | typing: {},
29 | sidebarOpen: false,
30 | userListOpen: window.innerWidth > 1000,
31 | }
32 |
33 | actions = {
34 | // --------------------------------------
35 | // UI
36 | // --------------------------------------
37 |
38 | setSidebar: sidebarOpen => this.setState({ sidebarOpen }),
39 | setUserList: userListOpen => this.setState({ userListOpen }),
40 |
41 | // --------------------------------------
42 | // User
43 | // --------------------------------------
44 |
45 | setUser: user => this.setState({ user }),
46 |
47 | // --------------------------------------
48 | // Room
49 | // --------------------------------------
50 |
51 | setRoom: room => {
52 | this.setState({ room, sidebarOpen: false })
53 | this.actions.scrollToEnd()
54 | },
55 |
56 | removeRoom: room => this.setState({ room: {} }),
57 |
58 | joinRoom: room => {
59 | this.actions.setRoom(room)
60 | this.actions.subscribeToRoom(room)
61 | this.state.messages[room.id] &&
62 | this.actions.setCursor(
63 | room.id,
64 | Object.keys(this.state.messages[room.id]).pop()
65 | )
66 | },
67 |
68 | subscribeToRoom: room =>
69 | !this.state.user.roomSubscriptions[room.id] &&
70 | this.state.user.subscribeToRoom({
71 | roomId: room.id,
72 | hooks: { onMessage: this.actions.addMessage },
73 | }),
74 |
75 | createRoom: options =>
76 | this.state.user.createRoom(options).then(this.actions.joinRoom),
77 |
78 | createConvo: options => {
79 | if (options.user.id !== this.state.user.id) {
80 | const exists = this.state.user.rooms.find(
81 | x =>
82 | x.name === options.user.id + this.state.user.id ||
83 | x.name === this.state.user.id + options.user.id
84 | )
85 | exists
86 | ? this.actions.joinRoom(exists)
87 | : this.actions.createRoom({
88 | name: this.state.user.id + options.user.id,
89 | addUserIds: [options.user.id],
90 | private: true,
91 | })
92 | }
93 | },
94 |
95 | addUserToRoom: ({ userId, roomId = this.state.room.id }) =>
96 | this.state.user
97 | .addUserToRoom({ userId, roomId })
98 | .then(this.actions.setRoom),
99 |
100 | removeUserFromRoom: ({ userId, roomId = this.state.room.id }) =>
101 | userId === this.state.user.id
102 | ? this.state.user.leaveRoom({ roomId })
103 | : this.state.user
104 | .removeUserFromRoom({ userId, roomId })
105 | .then(this.actions.setRoom),
106 |
107 | // --------------------------------------
108 | // Cursors
109 | // --------------------------------------
110 |
111 | setCursor: (roomId, position) =>
112 | this.state.user
113 | .setReadCursor({ roomId, position: parseInt(position) })
114 | .then(x => this.forceUpdate()),
115 |
116 | // --------------------------------------
117 | // Messages
118 | // --------------------------------------
119 |
120 | addMessage: payload => {
121 | const roomId = payload.room.id
122 | const messageId = payload.id
123 | // Update local message cache with new message
124 | this.setState(prevState => ({
125 | messages: {
126 | ...prevState.messages,
127 | [roomId]: {
128 | ...prevState.messages[roomId],
129 | [messageId]: payload
130 | }
131 | }
132 | }))
133 | // Update cursor if the message was read
134 | if (roomId === this.state.room.id) {
135 | const cursor = this.state.user.readCursor({ roomId }) || {}
136 | const cursorPosition = cursor.position || 0
137 | cursorPosition < messageId && this.actions.setCursor(roomId, messageId)
138 | this.actions.scrollToEnd()
139 | }
140 | // Send notification
141 | this.actions.showNotification(payload)
142 | },
143 |
144 | runCommand: command => {
145 | const commands = {
146 | invite: ([userId]) => this.actions.addUserToRoom({ userId }),
147 | remove: ([userId]) => this.actions.removeUserFromRoom({ userId }),
148 | leave: ([userId]) =>
149 | this.actions.removeUserFromRoom({ userId: this.state.user.id }),
150 | }
151 | const name = command.split(' ')[0]
152 | const args = command.split(' ').slice(1)
153 | const exec = commands[name]
154 | exec && exec(args).catch(console.log)
155 | },
156 |
157 | scrollToEnd: e =>
158 | setTimeout(() => {
159 | const elem = document.querySelector('#messages')
160 | elem && (elem.scrollTop = 100000)
161 | }, 0),
162 |
163 | // --------------------------------------
164 | // Typing Indicators
165 | // --------------------------------------
166 |
167 | isTyping: (room, user) =>
168 | this.setState(prevState => ({
169 | typing: {
170 | ...prevState.typing,
171 | [room.id]: {
172 | ...prevState.typing[room.id],
173 | [user.id]: true
174 | }
175 | }
176 | })),
177 |
178 | notTyping: (room, user) =>
179 | this.setState(prevState => ({
180 | typing: {
181 | ...prevState.typing,
182 | [room.id]: {
183 | ...prevState.typing[room.id],
184 | [user.id]: false
185 | }
186 | }
187 | })),
188 |
189 | // --------------------------------------
190 | // Presence
191 | // --------------------------------------
192 |
193 | setUserPresence: () => this.forceUpdate(),
194 |
195 | // --------------------------------------
196 | // Notifications
197 | // --------------------------------------
198 |
199 | showNotification: message => {
200 | if (
201 | 'Notification' in window &&
202 | this.state.user.id &&
203 | this.state.user.id !== message.senderId &&
204 | document.visibilityState === 'hidden'
205 | ) {
206 | const notification = new Notification(
207 | `New Message from ${message.sender.id}`,
208 | {
209 | body: message.text,
210 | icon: message.sender.avatarURL,
211 | }
212 | )
213 | notification.addEventListener('click', e => {
214 | this.actions.joinRoom(message.room)
215 | window.focus()
216 | })
217 | }
218 | },
219 | }
220 |
221 | componentDidMount() {
222 | 'Notification' in window && Notification.requestPermission()
223 | existingUser
224 | ? ChatManager(this, JSON.parse(existingUser))
225 | : fetch('https://chatkit-demo-server.herokuapp.com/auth', {
226 | method: 'POST',
227 | body: JSON.stringify({ code: authCode }),
228 | })
229 | .then(res => res.json())
230 | .then(user => {
231 | user.version = version
232 | window.localStorage.setItem('chatkit-user', JSON.stringify(user))
233 | window.history.replaceState(null, null, window.location.pathname)
234 | ChatManager(this, user)
235 | })
236 | }
237 |
238 | render() {
239 | const {
240 | user,
241 | room,
242 | messages,
243 | typing,
244 | sidebarOpen,
245 | userListOpen,
246 | } = this.state
247 | const { createRoom, createConvo, removeUserFromRoom } = this.actions
248 |
249 | return (
250 |
251 |
252 |
253 |
261 | {user.id && }
262 |
263 |
264 |
265 | {room.id ? (
266 |
267 |
268 |
273 |
274 |
275 |
276 | {userListOpen && (
277 |
283 | )}
284 |
285 | ) : user.id ? (
286 |
287 | ) : (
288 |
289 | )}
290 |
291 |
292 | )
293 | }
294 | }
295 |
296 | // --------------------------------------
297 | // Authentication
298 | // --------------------------------------
299 |
300 | window.localStorage.getItem('chatkit-user') &&
301 | !window.localStorage.getItem('chatkit-user').match(version) &&
302 | window.localStorage.clear()
303 |
304 | const params = new URLSearchParams(window.location.search.slice(1))
305 | const authCode = params.get('code')
306 | const existingUser = window.localStorage.getItem('chatkit-user')
307 |
308 | const githubAuthRedirect = () => {
309 | const client = '20cdd317000f92af12fe'
310 | const url = 'https://github.com/login/oauth/authorize'
311 | const server = 'https://chatkit-demo-server.herokuapp.com'
312 | const redirect = `${server}/success?url=${window.location.href.split('?')[0]}`
313 | window.location = `${url}?scope=user:email&client_id=${client}&redirect_uri=${redirect}`
314 | }
315 |
316 | !existingUser && !authCode
317 | ? githubAuthRedirect()
318 | : ReactDOM.render( , document.querySelector('#root'))
319 |
--------------------------------------------------------------------------------