├── README.md
├── index.html
├── main.go
├── site.css
└── site.js
/README.md:
--------------------------------------------------------------------------------
1 | # Proximity Chat
2 |
3 | A chat application that only allows for chatting in real-time with people that
4 | are within 500 meters of you. It works with the help of
5 | [Tile38](https://github.com/tidwall/tile38).
6 |
7 | This is my [Most Interesting Websocket Application Competition](https://www.meetup.com/Golang-Phoenix/events/252845809/) submission for the
8 | 7/31/18 Go Phoenix Meetup.
9 |
10 | ## Running
11 |
12 | Make sure that Tile38 is running.
13 |
14 | ```
15 | go run main.go
16 | ```
17 |
18 | Now go to http://localhost:8000
19 |
20 | GPS Tracking is turned off and the application is running in simulation mode.
21 | Drag your marker around the map.
22 | Open up another browser window and drag it's marker near the first marker.
23 | Now chat.
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 | TRACKING GPS IS OFF
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "log"
6 | "net/http"
7 | "strings"
8 | "sync"
9 | "time"
10 |
11 | "github.com/gomodule/redigo/redis"
12 | "github.com/gorilla/websocket"
13 | "github.com/tidwall/gjson"
14 | "github.com/tidwall/modern-server"
15 | )
16 |
17 | const dist = 500
18 |
19 | var (
20 | pool *redis.Pool // tile38 connection pool
21 | mu sync.Mutex // guard the connections
22 | all map[string]*websocket.Conn
23 | )
24 |
25 | func main() {
26 | var tile38Addr string
27 |
28 | all = make(map[string]*websocket.Conn)
29 |
30 | server.Main(func(w http.ResponseWriter, r *http.Request) {
31 | if r.URL.Path == "/ws" {
32 | handleWS(w, r)
33 | } else {
34 | server.HandleFiles(w, r)
35 | }
36 | }, &server.Options{
37 | Version: "0.0.1",
38 | Name: "proximity-chat",
39 | Flags: func() {
40 | flag.StringVar(&tile38Addr, "tile38", ":9851", "")
41 | },
42 | FlagsParsed: func() {
43 | // Tile38 connection pool
44 | pool = &redis.Pool{
45 | MaxIdle: 16,
46 | IdleTimeout: 240 * time.Second,
47 | Dial: func() (redis.Conn, error) {
48 | return redis.Dial("tcp", tile38Addr)
49 | },
50 | }
51 | go monitorAll()
52 | },
53 | Usage: func(usage string) string {
54 | usage = strings.Replace(usage, "{{USAGE}}",
55 | " -tile38 addr : "+
56 | "use the specified Tile38 server (default: *:9851)\n", -1)
57 | return usage
58 | },
59 | })
60 | }
61 |
62 | func monitorAll() {
63 | for {
64 | func() {
65 | conn := pool.Get()
66 | defer func() {
67 | conn.Close()
68 | time.Sleep(time.Second)
69 | }()
70 | resp, err := redis.String(conn.Do(
71 | "INTERSECTS", "people", "FENCE", "BOUNDS", -90, -180, 90, 180))
72 | if err != nil || resp != "OK" {
73 | log.Printf("nearby: %v", err)
74 | return
75 | }
76 | log.Printf("monitor geofence connected")
77 | for {
78 | msg, err := redis.Bytes(conn.Receive())
79 | if err != nil {
80 | log.Printf("monitor: %v", err)
81 | return
82 | }
83 | mu.Lock()
84 | for _, c := range all {
85 | c.WriteMessage(1, msg)
86 | }
87 | mu.Unlock()
88 | }
89 | }()
90 | }
91 | }
92 |
93 | func handleWS(w http.ResponseWriter, r *http.Request) {
94 | var upgrader = websocket.Upgrader{}
95 | c, err := upgrader.Upgrade(w, r, nil)
96 | if err != nil {
97 | log.Printf("upgrade: %v", err)
98 | return
99 | }
100 |
101 | var meID string
102 | defer func() {
103 | // unregister connection
104 | mu.Lock()
105 | delete(all, meID)
106 | mu.Unlock()
107 | c.Close()
108 | log.Printf("disconnected")
109 | }()
110 |
111 | log.Printf("connected")
112 | for {
113 | _, bmsg, err := c.ReadMessage()
114 | if err != nil {
115 | log.Printf("read: %v", err)
116 | break
117 | }
118 | msg := string(bmsg)
119 | switch {
120 | case gjson.Get(msg, "type").String() == "Message":
121 | feature := gjson.Get(msg, "feature").String()
122 | func() {
123 | c := pool.Get()
124 | defer c.Close()
125 | replys, err := redis.Values(c.Do("NEARBY", "people",
126 | "IDS", "POINT",
127 | gjson.Get(feature, "geometry.coordinates.1").Float(),
128 | gjson.Get(feature, "geometry.coordinates.0").Float(),
129 | dist,
130 | ))
131 | if err != nil {
132 | log.Printf("%v", err)
133 | return
134 | }
135 | if len(replys) > 1 {
136 | ids, _ := redis.Strings(replys[1], nil)
137 | for _, id := range ids {
138 | mu.Lock()
139 | if c := all[id]; c != nil {
140 | c.WriteMessage(1, bmsg)
141 | }
142 | mu.Unlock()
143 | }
144 | }
145 | }()
146 | case gjson.Get(msg, "type").String() == "Feature":
147 | id := gjson.Get(msg, "properties.id").String()
148 | if id == "" {
149 | break
150 | }
151 | if meID == "" {
152 | meID = id
153 | // register connection
154 | mu.Lock()
155 | all[meID] = c
156 | mu.Unlock()
157 | }
158 | func() {
159 | c := pool.Get()
160 | defer c.Close()
161 | c.Do("SET", "people", id, "EX", 5, "OBJECT", msg)
162 | }()
163 | }
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/site.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 | body, html {
5 | border: 0;
6 | margin: 0;
7 | padding: 0;
8 | height: 100%;
9 | width: 100%;
10 | overflow: hidden;
11 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif
12 | }
13 |
14 | #track-button {
15 | position: absolute;
16 | z-index: 10000;
17 | top: 10px;
18 | left: 10px;
19 | background: #36c;
20 | color: white;
21 | font-size: 16px;
22 | border: 0px;
23 | width: 200px;
24 | line-height: 28px;
25 | border-radius: 6px;
26 | cursor: pointer;
27 | }
28 |
29 | #track-button:hover {
30 | background: #14a;
31 | }
32 |
33 | #track-button:active {
34 | background: #028;
35 | }
36 |
37 | #map {
38 | position: absolute;
39 | width: 70%;
40 | height: 100%;
41 | float: left;
42 | }
43 |
44 | #chat {
45 | margin-left: 70%;
46 | width: 30%;
47 | height: 100%;
48 | background: #eee;
49 | }
50 |
51 | #chat-area{
52 | padding: 0 11px;
53 | overflow-y: scroll;
54 | }
55 |
56 | #chat-template{
57 | background:yellow;
58 | }
59 |
60 | #messages{
61 | background:blue;
62 | width: 100%;
63 | height: 100%;
64 | padding-bottom: 50px;
65 | }
66 |
67 | * {
68 | outline-width: 0;
69 | }
70 |
71 | #chat-input{
72 | width: 100%;
73 | font-size: 20px;
74 | border: 0;
75 | padding: 10px;
76 | }
77 |
78 | .marker {
79 | background-color: rgba(18, 157, 212, 0.7);
80 | background-size: cover;
81 | width: 24px;
82 | height: 24px;
83 | border: 3px solid white;
84 | border-radius: 50%;
85 | }
86 |
87 | #marker-dot {
88 | background-color: white;
89 | background-size: cover;
90 | width: 10px;
91 | height: 10px;
92 | border-radius: 50%;
93 | margin-top: 27px;
94 | margin-left: 94px;
95 | position: absolute;
96 | }
97 |
98 | .marker input {
99 | color:black;
100 | text-align: center;
101 | background: transparent;
102 | text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;
103 | border: 0;
104 | width: 200px;
105 | height: 28px;
106 | font-size: 15px;
107 | position: relative;
108 | left: -92px;
109 | top: -30px;
110 | }
111 |
112 | .marker div {
113 | color:black;
114 | text-align: center;
115 | background: transparent;
116 | text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;
117 | border: 0;
118 | width: 200px;
119 | height: 28px;
120 | font-size: 15px;
121 | position: relative;
122 | left: -90px;
123 | top: -23px;
124 | }
125 |
126 |
--------------------------------------------------------------------------------
/site.js:
--------------------------------------------------------------------------------
1 | var me;
2 | var ws;
3 | var opened;
4 | var marker;
5 | var markers = {};
6 | var chatItems = [];
7 | var chatInput;
8 |
9 |
10 | function meUpdated() {
11 | var memsg = JSON.stringify(me);
12 | sessionStorage.setItem("t38.me", memsg);
13 | sendMe(memsg);
14 | }
15 |
16 | function sendMe(memsg) {
17 | if (!memsg) {
18 | memsg = JSON.stringify(me);
19 | }
20 | if (!opened) {
21 | return;
22 | }
23 | ws.send(memsg);
24 | }
25 |
26 | function calcNearby() {
27 | var connected;
28 | for (id in markers) {
29 | pmarker = markers[id];
30 | var meters = distance(pmarker, marker);
31 | var layerName = "l:" + id;
32 | var sourceName = "s:" + id;
33 | if (meters < 500) {
34 | var data = {
35 | "type": "Feature", "properties": {},
36 | "geometry": {
37 | "type": "LineString",
38 | "coordinates": [
39 | me.geometry.coordinates,
40 | pmarker.person.geometry.coordinates,
41 | ]
42 | }
43 | }
44 | if (map.getSource(sourceName)) {
45 | map.getSource(sourceName).setData(data);
46 | } else {
47 | map.addSource(sourceName, { type: 'geojson', data: data });
48 | map.addLayer({
49 | "id": layerName,
50 | "type": "line",
51 | "source": sourceName,
52 | "layout": {
53 | "line-join": "round",
54 | "line-cap": "round"
55 | },
56 | "paint": {
57 | "line-color": "#aa6600",
58 | "line-width": 3
59 | }
60 | });
61 | }
62 | pmarker.getElement().style.borderColor = "#aa6600";
63 | pmarker.connected = true;
64 | connected = true;
65 | } else {
66 | if (map.getLayer(layerName)) {
67 | map.removeLayer(layerName);
68 | }
69 | if (map.getSource(sourceName)) {
70 | map.removeSource(sourceName);
71 | }
72 | pmarker.getElement().style.borderColor = null;
73 | pmarker.connected = false;
74 | }
75 | }
76 | if (connected) {
77 | marker.getElement().style.borderColor = "#aa6600";
78 | document.getElementById('marker-dot').style.color = "#aa6600";
79 | } else {
80 | marker.getElement().style.borderColor = null;
81 | document.getElementById('marker-dot').style.color = null;
82 | }
83 | }
84 |
85 | function openWS() {
86 | ws = new WebSocket("ws://" + location.host + "/ws");
87 | ws.onopen = function () {
88 | opened = true;
89 | meUpdated();
90 | }
91 | ws.onclose = function () {
92 | opened = false;
93 | setTimeout(function () { openWS() }, 1000)
94 | }
95 | ws.onmessage = function (e) {
96 | var msg = JSON.parse(e.data);
97 | if (msg.id == me.properties.id) {
98 | return;
99 | }
100 | switch (msg.command) {
101 | case "set":
102 | if (!markers[msg.id]) {
103 | markers[msg.id] = makeMarker(false, msg.object);
104 | markers[msg.id].addTo(map);
105 | } else {
106 | markers[msg.id].setLngLat(msg.object.geometry.coordinates);
107 |
108 | markers[msg.id].getElement().
109 | querySelector(".marker-name").innerText =
110 | msg.object.properties.name ?
111 | msg.object.properties.name :
112 | 'Anonymous';
113 | }
114 | markers[msg.id].person = msg.object;
115 | break;
116 | case "del":
117 | if (markers[msg.id]) {
118 | if (markers[msg.id].connected) {
119 | var layerName = "l:" + id;
120 | var sourceName = "s:" + id;
121 | if (map.getLayer(layerName)) {
122 | map.removeLayer(layerName);
123 | }
124 | if (map.getSource(sourceName)) {
125 | map.removeSource(sourceName);
126 | }
127 | }
128 | markers[msg.id].remove(map);
129 | delete markers[msg.id];
130 | }
131 | break;
132 | default:
133 | if (msg.type == "Message") {
134 | chatItems.push(msg);
135 | chatUpdate();
136 | }
137 | break;
138 | }
139 | calcNearby();
140 | }
141 | }
142 |
143 |
144 | function makeMarker(isme, person) {
145 | // add self marker
146 | var el = document.createElement('div');
147 | el.className = 'marker';
148 | el.style.backgroundColor = person.properties.color;
149 | if (isme) {
150 | var ed = document.createElement('input');
151 | ed.value = person.properties.name ? person.properties.name : '';
152 | ed.type = 'text';
153 | ed.placeholder = 'enter your name';
154 | ed.id = 'name';
155 | ed.autocomplete = 'off';
156 | ed.maxLength = 28;
157 | ed.onkeypress = ed.onchange = ed.onkeyup = function (ev) {
158 | person.properties.name = ed.value.trim();
159 | meUpdated()
160 | if (ev.charCode == 13) {
161 | this.blur();
162 | }
163 | }
164 | el.appendChild(ed);
165 | el.style.zIndex = 10000;
166 | el.style.cursor = "move";
167 | var dot = document.createElement('div');
168 | dot.id = "marker-dot";
169 | el.appendChild(dot);
170 | } else {
171 | var ed = document.createElement('div');
172 | ed.className = 'marker-name';
173 | ed.innerText =
174 | person.properties.name ? person.properties.name : 'Anonymous';
175 | el.appendChild(ed);
176 | }
177 | var marker = new mapboxgl.Marker({
178 | element: el,
179 | draggable: isme
180 | })
181 | marker.setLngLat(person.geometry.coordinates);
182 | return marker
183 | }
184 |
185 |
186 | function distance(latA, lonA, latB, lonB) {
187 | if (arguments.length == 2) {
188 | var a = latA.getLngLat();
189 | var b = lonA.getLngLat();
190 | latA = a.lat;
191 | lonA = a.lng;
192 | latB = b.lat;
193 | lonB = b.lng;
194 | }
195 |
196 | // a = sin²(Δφ/2) + cos(φ1)⋅cos(φ2)⋅sin²(Δλ/2)
197 | // tanδ = √(a) / √(1−a)
198 | // see mathforum.org/library/drmath/view/51879.html for derivation
199 |
200 | var R = 6371e3;
201 | var φ1 = latA * Math.PI / 180, λ1 = lonA * Math.PI / 180;
202 | var φ2 = latB * Math.PI / 180, λ2 = lonB * Math.PI / 180;
203 | var Δφ = φ2 - φ1;
204 | var Δλ = λ2 - λ1;
205 |
206 | var a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2)
207 | + Math.cos(φ1) * Math.cos(φ2)
208 | * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
209 | var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
210 | var d = R * c;
211 |
212 | return d;
213 | };
214 |
215 |
216 | function chatUpdate() {
217 | var chatArea = document.getElementById("chat-area");
218 | chatArea.innerHTML = '';
219 | for (var i = chatItems.length - 1; i >= 0; i--) {
220 | var item = chatItems[i];
221 | if (!item.dist) {
222 | if (item.feature.properties.id == me.properties.id) {
223 | item.dist = 0;
224 | } else {
225 | item.dist = distance(
226 | item.feature.geometry.coordinates[1],
227 | item.feature.geometry.coordinates[0],
228 | me.geometry.coordinates[1],
229 | me.geometry.coordinates[0]
230 | )
231 | }
232 | }
233 | var name = item.feature.properties.name || "Anonymous";
234 | var el = document.createElement("div");
235 | el.innerHTML = " - " +
236 | "
";
237 | el.style.marginBottom = "5px";
238 | el.querySelector(".c1").innerText = name
239 | el.querySelector(".c2").innerText = item.dist.toFixed(0) + "m";
240 | el.querySelector(".c3").innerText = item.text;
241 | chatArea.appendChild(el);
242 | }
243 | }
244 |
245 |
246 | chatInput = document.getElementById('chat-input');
247 |
248 | // load me
249 | me = JSON.parse(sessionStorage.getItem("t38.me"));
250 | if (!me) {
251 | me = {
252 | type: "Feature",
253 | geometry: {
254 | type: "Point",
255 | coordinates: [
256 | -112.0669412 + (Math.random() * 0.01) - 0.005,
257 | 33.44146890 + (Math.random() * 0.01) - 0.005
258 | ]
259 | },
260 | properties: {
261 | id: (Math.random()).toString(16).slice(2),
262 | color: "rgba(" +
263 | Math.floor(Math.random() * 128 + 128) + "," +
264 | Math.floor(Math.random() * 128 + 128) + "," +
265 | Math.floor(Math.random() * 128 + 128) + "," +
266 | "1.0)"
267 | }
268 | };
269 | me.properties.center = me.geometry.coordinates;
270 | me.properties.zoom = 14;
271 | sessionStorage.setItem("t38.me", JSON.stringify(me));
272 | }
273 | // load the map
274 | mapboxgl.accessToken = 'pk.eyJ1IjoidGlkd2FsbCIsImEiOiJjams3Z21yZDUxZXg1M2tuYzhhcHUyOWZnIn0.9KIyO_Az2Ui8_k13m7Fw_g';
275 | var map = new mapboxgl.Map({
276 | container: 'map',
277 | style: 'mapbox://styles/mapbox/streets-v9',
278 | center: me.properties.center,
279 | zoom: me.properties.zoom,
280 | keyboard: false
281 | });
282 | map.on("load", function () {
283 | // track map position and zoom
284 | var onmap = function () {
285 | me.properties.center = [map.getCenter().lng, map.getCenter().lat];
286 | me.properties.zoom = map.getZoom();
287 | meUpdated();
288 | }
289 | map.on("drag", onmap);
290 | map.on("zoom", onmap);
291 |
292 | marker = makeMarker(true, me);
293 | marker.addTo(map);
294 | marker.on("drag", function () {
295 | me.geometry.coordinates =
296 | [marker.getLngLat().lng, marker.getLngLat().lat];
297 | meUpdated();
298 | calcNearby();
299 | })
300 |
301 | chatInput.addEventListener('keypress', function (ev) {
302 | if (ev.charCode == 13) {
303 | var phrase = this.value.trim();
304 | if (opened && phrase != "") {
305 | var msg = {
306 | "type": "Message",
307 | "feature": me,
308 | "text": phrase
309 | }
310 | ws.send(JSON.stringify(msg))
311 | this.value = '';
312 | }
313 | }
314 | })
315 |
316 | openWS()
317 | setInterval(function () { sendMe() }, 500)
318 | })
319 |
320 | var resize = function () {
321 | var chat = document.getElementById("chat-area");
322 | chat.style.height =
323 | (document.body.offsetHeight - chatInput.offsetHeight - 11) + "px";
324 | chat.style.marginTop = "11px";
325 | }
326 | window.addEventListener("resize", resize);
327 | resize();
328 |
329 | var tracking = false;
330 | var trackButton = document.getElementById("track-button");
331 | trackButton.addEventListener("click", function () {
332 | navigator.geolocation.getCurrentPosition(function (position, error) {
333 |
334 | });
335 | })
--------------------------------------------------------------------------------