├── 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 |
13 | 14 |
15 |
16 | 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 | }) --------------------------------------------------------------------------------