├── .eslintrc.json
├── README.md
├── main.go
└── static
├── index.html
└── script.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 6,
4 | "sourceType": "module",
5 | "ecmaFeatures": {
6 | "experimentalObjectRestSpread": true
7 | }
8 | },
9 | "rules": {
10 | "semi": "error",
11 | "comma-dangle": "off",
12 | "no-console": "off",
13 | "keyword-spacing": "warn",
14 | "max-len": "warn",
15 | "key-spacing": "error",
16 | "prefer-const": "error",
17 | "space-before-function-paren": ["error", "never"],
18 | "space-before-blocks": ["warn", "always"],
19 | "arrow-spacing": "warn",
20 | "eqeqeq": ["error", "smart"],
21 | "no-constant-condition": "off",
22 | "curly": "error",
23 | "no-var": "warn",
24 | "object-shorthand": "warn"
25 | },
26 | "extends": [
27 | "eslint:recommended"
28 | ],
29 | "env": {
30 | "browser": true,
31 | "commonjs": true,
32 | "es6": true
33 | },
34 | "globals": {
35 | "Scaledrone": true
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Scaledrone Go Chat App Tutorial
2 |
3 | [Check out the tutorial.](https://www.scaledrone.com/blog/go-chat-app-tutorial-build-a-real-time-chat/)
4 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math/rand"
6 | "net/http"
7 | "strconv"
8 | "time"
9 |
10 | jwt "github.com/dgrijalva/jwt-go"
11 | "github.com/gorilla/mux"
12 | )
13 |
14 | const (
15 | scaledroneID = "YOUR_SCALEDRONE_ID" // 👈 PS! Replace this with your own channel ID 🚨
16 | scaledroneSecret = "YOUR_SCALEDRONE_SECRET" // 👈 PS! Replace this with your own channel secret 🚨
17 | port = ":8080"
18 | )
19 |
20 | func main() {
21 | r := mux.NewRouter()
22 | r.HandleFunc("/auth", auth).Methods("POST")
23 | r.PathPrefix("/").Handler(http.FileServer(http.Dir("./static"))).Methods("GET")
24 | fmt.Printf("Server is running on localhost%s", port)
25 | panic(http.ListenAndServe(port, r))
26 | }
27 |
28 | type customClaims struct {
29 | jwt.StandardClaims
30 | Client string `json:"client"`
31 | Channel string `json:"channel"`
32 | Data userData `json:"data"`
33 | Permissions map[string]permissionClaims `json:"permissions"`
34 | }
35 |
36 | type permissionClaims struct {
37 | Publish bool `json:"publish"`
38 | Subscribe bool `json:"subscribe"`
39 | }
40 |
41 | type userData struct {
42 | Color string `json:"color"`
43 | Name string `json:"name"`
44 | }
45 |
46 | func getRandomName() string {
47 | adjs := []string{"autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark", "summer", "icy", "delicate", "quiet", "white", "cool", "spring", "winter", "patient", "twilight", "dawn", "crimson", "wispy", "weathered", "blue", "billowing", "broken", "cold", "damp", "falling", "frosty", "green", "long", "late", "lingering", "bold", "little", "morning", "muddy", "old", "red", "rough", "still", "small", "sparkling", "throbbing", "shy", "wandering", "withered", "wild", "black", "young", "holy", "solitary", "fragrant", "aged", "snowy", "proud", "floral", "restless", "divine", "polished", "ancient", "purple", "lively", "nameless"}
48 | nouns := []string{"waterfall", "river", "breeze", "moon", "rain", "wind", "sea", "morning", "snow", "lake", "sunset", "pine", "shadow", "leaf", "dawn", "glitter", "forest", "hill", "cloud", "meadow", "sun", "glade", "bird", "brook", "butterfly", "bush", "dew", "dust", "field", "fire", "flower", "firefly", "feather", "grass", "haze", "mountain", "night", "pond", "darkness", "snowflake", "silence", "sound", "sky", "shape", "surf", "thunder", "violet", "water", "wildflower", "wave", "water", "resonance", "sun", "wood", "dream", "cherry", "tree", "fog", "frost", "voice", "paper", "frog", "smoke", "star"}
49 | return adjs[rand.Intn(len(adjs))] + "_" + nouns[rand.Intn(len(nouns))]
50 | }
51 |
52 | func getRandomColor() string {
53 | return "#" + strconv.FormatInt(rand.Int63n(0xFFFFFF), 16)
54 | }
55 |
56 | func auth(w http.ResponseWriter, r *http.Request) {
57 | clientID := r.FormValue("clientID")
58 | if clientID == "" {
59 | http.Error(w, "No clientID defined", http.StatusUnprocessableEntity)
60 | return
61 | }
62 |
63 | // public room
64 | publicRoomRegex := "^observable-room$"
65 | // private room of the request user
66 | userPrivateRoomRegex := fmt.Sprintf("^private-room-%s$", clientID)
67 | // private rooms of every user besides the request user
68 | otherUsersPrivateRoomsRegex := fmt.Sprintf("^private-room-(?!%s$).+$", clientID)
69 | claims := customClaims{
70 | StandardClaims: jwt.StandardClaims{
71 | ExpiresAt: time.Now().Add(time.Minute * 3).Unix(),
72 | },
73 | Client: clientID,
74 | Channel: scaledroneID,
75 | Data: userData{
76 | Name: getRandomName(),
77 | Color: getRandomColor(),
78 | },
79 | Permissions: map[string]permissionClaims{
80 | publicRoomRegex: permissionClaims{ // public room
81 | Publish: true, // allow publishing to public chatroom
82 | Subscribe: true, // allow subscribing to public chatroom
83 | },
84 | userPrivateRoomRegex: permissionClaims{
85 | Publish: false, // no need to publish to ourselves
86 | Subscribe: true, // allow subscribing to private messages
87 | },
88 | otherUsersPrivateRoomsRegex: permissionClaims{
89 | Publish: true, // allow publishing to other users
90 | Subscribe: false, // don't allow subscribing to messages sent to other users
91 | },
92 | },
93 | }
94 |
95 | // Create a new token
96 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
97 |
98 | // Sign the token with our secret
99 | tokenString, err := token.SignedString([]byte(scaledroneSecret))
100 | if err != nil {
101 | http.Error(w, "Unable to sign the token", http.StatusUnprocessableEntity)
102 | return
103 | }
104 | // Send the token to the user
105 | w.Write([]byte(tokenString))
106 | }
107 |
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
86 |
87 |
88 |
89 |
Your name
90 |
91 |
Public rooms
92 |
93 |
Global public room
94 |
95 |
Connected users
96 |
97 |
98 |
99 |
Global public room
100 |
101 |
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/static/script.js:
--------------------------------------------------------------------------------
1 | // 👇 PS! Replace this with your own channel ID 🚨
2 | const CLIENT_ID = 'YOUR_SCALEDRONE_ID';
3 |
4 | // public room
5 | const PUBLIC_ROOM_NAME = 'observable-room';
6 | // array of connected memebers
7 | let members = [];
8 | // the session user
9 | let me;
10 | // keeping track of which room the user has selected
11 | let selectedRoom = PUBLIC_ROOM_NAME;
12 | // room name to messages map, this is used to store messages for displaying them
13 | // at a later state
14 | const roomMessages = {};
15 |
16 | const drone = new Scaledrone(CLIENT_ID);
17 |
18 | drone.on('open', error => {
19 | if (error) {
20 | return console.error(error);
21 | }
22 | // get JWT from the Go server for the clientID
23 | const formData = new FormData();
24 | formData.append('clientID', drone.clientId);
25 | fetch('/auth', {body: formData, method: 'POST'})
26 | .then(res => res.text())
27 | .then(jwt => drone.authenticate(jwt));
28 | });
29 |
30 | drone.on('authenticate', error => {
31 | if (error) {
32 | return console.error(error);
33 | }
34 | console.log('Successfully connected to Scaledrone');
35 | joinPublicRoom();
36 | joinPersonalRoom();
37 | });
38 |
39 | // Start subscribing to messages from the public room
40 | function joinPublicRoom() {
41 | const publicRoom = drone.subscribe(PUBLIC_ROOM_NAME);
42 | publicRoom.on('open', error => {
43 | if (error) {
44 | return console.error(error);
45 | }
46 | console.log(`Successfully joined room ${PUBLIC_ROOM_NAME}`);
47 | });
48 |
49 | // Received array of members currently connected to the public room
50 | publicRoom.on('members', m => {
51 | members = m;
52 | me = members.find(m => m.id === drone.clientId);
53 | DOM.updateMembers();
54 | });
55 |
56 | // New member joined the public room
57 | publicRoom.on('member_join', member => {
58 | members.push(member);
59 | DOM.updateMembers();
60 | });
61 |
62 | // Member left public room (closed browser tab)
63 | publicRoom.on('member_leave', ({id}) => {
64 | const index = members.findIndex(member => member.id === id);
65 | members.splice(index, 1);
66 | DOM.updateMembers();
67 | });
68 |
69 | // Received public message
70 | publicRoom.on('message', message => {
71 | const {data, member} = message;
72 | if (member && member !== me) {
73 | addMessageToRoomArray(PUBLIC_ROOM_NAME, member, data);
74 | if (selectedRoom === PUBLIC_ROOM_NAME) {
75 | DOM.addMessageToList(data, member);
76 | }
77 | }
78 | });
79 | }
80 |
81 | // Start subscribing to messages from my private room (PMs to me)
82 | function joinPersonalRoom() {
83 | const roomName = createPrivateRoomName(drone.clientId);
84 | const myRoom = drone.subscribe(roomName);
85 | myRoom.on('open', error => {
86 | if (error) {
87 | return console.error(error);
88 | }
89 | console.log(`Successfully joined room ${roomName}`);
90 | });
91 |
92 | myRoom.on('message', message => {
93 | const {data, clientId} = message;
94 | const member = members.find(m => m.id === clientId);
95 | if (member) {
96 | addMessageToRoomArray(createPrivateRoomName(member.id), member, data);
97 | if (selectedRoom === createPrivateRoomName(clientId)) {
98 | DOM.addMessageToList(data, member);
99 | }
100 | } else {
101 | /* Message is sent from golang using the REST API.
102 | * You can handle it like a regular message but it won't have a connection
103 | * session attached to it (this means no member argument)
104 | */
105 | }
106 | });
107 | }
108 |
109 | drone.on('close', event => {
110 | console.log('Connection was closed', event);
111 | });
112 |
113 | drone.on('error', error => {
114 | console.error(error);
115 | });
116 |
117 | function changeRoom(name, roomName) {
118 | selectedRoom = roomName;
119 | DOM.updateChatTitle(name);
120 | DOM.clearMessages();
121 | if (roomMessages[roomName]) {
122 | roomMessages[roomName].forEach(({data, member}) =>
123 | DOM.addMessageToList(data, member)
124 | );
125 | }
126 | }
127 |
128 | function createPrivateRoomName(clientId) {
129 | return `private-room-${clientId}`;
130 | }
131 |
132 | function addMessageToRoomArray(roomName, member, data) {
133 | console.log('add', roomName, member.id, data);
134 | roomMessages[roomName] = roomMessages[roomName] || [];
135 | roomMessages[roomName].push({member, data});
136 | }
137 |
138 | //------------- DOM Manipulation / Rendering the UI
139 |
140 | const DOM = {
141 | elements: {
142 | me: document.querySelector('.me'),
143 | membersList: document.querySelector('.members-list'),
144 | messages: document.querySelector('.messages'),
145 | input: document.querySelector('.message-form__input'),
146 | form: document.querySelector('.message-form'),
147 | chatTitle: document.querySelector('.chat-title'),
148 | room: document.querySelector('.room'),
149 | },
150 |
151 | // Send message to Scaledrone and clear the input
152 | sendMessage() {
153 | const {input} = this.elements;
154 | const value = input.value;
155 | if (value === '') {
156 | return;
157 | }
158 | input.value = '';
159 | drone.publish({
160 | room: selectedRoom,
161 | message: value,
162 | });
163 | addMessageToRoomArray(selectedRoom, me, value);
164 | this.addMessageToList(value, me);
165 | },
166 |
167 | // Create DOM element with member name and color
168 | createMemberElement(member) {
169 | const { name, color } = member.authData;
170 | const el = document.createElement('div');
171 | el.appendChild(document.createTextNode(name));
172 | el.className = 'member';
173 | el.style.color = color;
174 | if (member !== me) {
175 | // Listen to user clicking on another user
176 | el.addEventListener('click', () =>
177 | changeRoom(member.authData.name, createPrivateRoomName(member.id))
178 | );
179 | }
180 | return el;
181 | },
182 |
183 | // Rerender the list of connected members
184 | updateMembers() {
185 | this.elements.me.innerHTML = '';
186 | this.elements.me.appendChild(this.createMemberElement(me));
187 | this.elements.membersList.innerHTML = '';
188 | members.filter(m => m !== me).forEach(member =>
189 | this.elements.membersList.appendChild(this.createMemberElement(member))
190 | );
191 | },
192 |
193 | // Create a DOM element for the message
194 | createMessageElement(text, member) {
195 | const el = document.createElement('div');
196 | el.appendChild(this.createMemberElement(member));
197 | el.appendChild(document.createTextNode(text));
198 | el.className = 'message';
199 | return el;
200 | },
201 |
202 | // Add message element to the messages container
203 | addMessageToList(text, member) {
204 | const el = this.elements.messages;
205 | const wasTop = el.scrollTop === el.scrollHeight - el.clientHeight;
206 | el.appendChild(this.createMessageElement(text, member));
207 | if (wasTop) {
208 | el.scrollTop = el.scrollHeight - el.clientHeight;
209 | }
210 | },
211 |
212 | updateChatTitle(roomName) {
213 | this.elements.chatTitle.innerText = roomName;
214 | },
215 |
216 | clearMessages() {
217 | this.elements.messages.innerHTML = '';
218 | },
219 | };
220 | // Listen to submitting the input form
221 | DOM.elements.form.addEventListener('submit', () =>
222 | DOM.sendMessage()
223 | );
224 | // Listen to user clicking on the public room label
225 | DOM.elements.room.addEventListener('click', () =>
226 | changeRoom('Public room', PUBLIC_ROOM_NAME)
227 | );
228 |
--------------------------------------------------------------------------------