First MeshMessenger with WebRTC video support.
Ylian Saint-Hilaire committed
Dec 17, 2018 at 00:32 UTC
de76f812d5724877f79353dea2bfcd53e89b4495
6 files changed
+192
-83
package.json
+1
-1
@@ -1,6 +1,6 @@
1
{
2
"name": "meshcentral",
3
- "version": "0.2.4-q",
3
+ "version": "0.2.4-s",
4
"keywords": [
5
"Remote Management",
6
"Intel AMT",
public/images/messenger16.png
Binary files a/public/images/messenger16.png and b/public/images/messenger16.png differ
public/images/messenger32.png
Binary files a/public/images/messenger32.png and b/public/images/messenger32.png differ
public/images/messenger64.png
Binary files a/public/images/messenger64.png and b/public/images/messenger64.png differ
public/messenger.htm
+162
-81
@@ -10,12 +10,13 @@
10
<script type="text/javascript" src="scripts/common-0.0.1.js"></script>
11
</head>
12
<body style="font-family:Arial,Helvetica,sans-serif">
13
- <div id="xtop" style="position:absolute;left:0;right:0;top:0;height:38px;background-color:#036;color:#c8c8c8">
13
+ <div id="xtop" style="position:absolute;left:0;right:0;top:0;height:38px;background-color:#036;color:#c8c8c8;box-shadow:3px 3px 10px gray">
14
<div id="fileButton" class="icon4 topButton" title="Share a file" style="display:none" onclick="fileButtonClick()"></div>
15
<div id="camButton" class="icon2 topButton" title="Activate camera & microphone" style="display:none" onclick="camButtonClick()"></div>
16
<div id="micButton" class="icon6 topButton" title="Activate microphone" style="display:none" onclick="micButtonClick()"></div>
17
+ <div id="hangupButton" class="icon11 topButton" title="Hang up" style="display:none" onclick="hangUpButtonClick(1)"></div>
18
<div style="display:inline-block;width:2px"></div>
18
- <div style="padding-top:9px;padding-left:6px;font-size:20px;display:inline-block;"><b>MeshMessenger<span id="xtitle"></span></b></div>
19
+ <div style="padding-top:9px;padding-left:6px;font-size:20px;display:inline-block"><b>MeshMessenger<span id="xtitle"></span></b></div>
20
</div>
21
<div id="xmiddle" style="position:absolute;left:0;right:0;top:38px;bottom:30px">
22
<div style="position:absolute;left:0;right:0;top:0;bottom:0;overflow-y:scroll">
@@ -27,37 +28,45 @@
28
<input type="button" id="sendButton" value="Send" style="position:absolute;right:110px;width:100px;top:4px;" onclick="xsend(event)" />
29
<input type="button" id="clearButton" value="Clear" style="position:absolute;right:5px;width:100px;top:4px;" onclick="displayClear()" />
30
</div>
30
- <div id="localVideo" style="position:absolute;right:24px;top:45px;width:320px;height:260px;background-color:black;border-radius:12px 12px 0px 0px;box-shadow:3px 3px 10px gray;display:none">
31
- <div style="position:absolute;right:0;left:0;top:0px;height:20px;background-color:gray;text-align:center;border-radius:10px 10px 0px 0px"><div style="padding:3px">Local</div></div>
32
- <video id="localVideoCanvas" autoplay muted style="position:absolute;top:20px;left:0;width:320px;height:240px;background-color:black"></video>
31
+ <div id="remoteVideo" style="position:absolute;right:24px;top:45px;width:320px;height:calc(240px + 30px);background-color:gray;border-radius:12px 12px 12px 12px;box-shadow:3px 3px 10px gray;display:none">
32
+ <div style="position:absolute;right:0;left:0;top:2.5px;text-align:center">Remote</div>
33
+ <video id="remoteVideoCanvas" autoplay style="position:absolute;top:20px;left:0;width:100%;height:calc(100% - 30px);background-color:black"></video>
34
</div>
34
- <div id="remoteVideo" style="position:absolute;left:6px;top:45px;width:320px;height:260px;background-color:black;border-radius:12px 12px 0px 0px;display:none">
35
- <div style="position:absolute;right:0;left:0;top:0px;height:20px;background-color:gray;text-align:center;border-radius:10px 10px 0px 0px"><div style="padding:3px">Remote</div></div>
36
- <video id="remoteVideoCanvas" autoplay style="position:absolute;top:20px;left:0;width:320px;height:240px;background-color:black"></video>
35
+ <div id="localVideo" style="position:absolute;right:24px;top:320px;width:160px;height:calc(120px + 30px);background-color:gray;border-radius:12px 12px 12px 12px;box-shadow:3px 3px 10px gray;display:none">
36
+ <div style="position:absolute;right:0;left:0;top:2.5px;text-align:center">Local</div>
37
+ <video id="localVideoCanvas" autoplay muted style="position:absolute;top:20px;left:0;width:100%;height:calc(100% - 30px);background-color:black"></video>
38
</div>
39
<script type="text/javascript">
40
var userInputFocus = 0;
40
- var controlsEnabled = true;
41
var args = parseUriArgs();
42
- var socket = null; // Websocket object
43
- var state = 0; // Connection state. 0 = Disconnected, 1 = Connecting, 2 = Connected.
44
- var random = Math.random(); // Selected random, larger value initiates WebRTC.
45
- var webrtc = null; // Main WebRTC object
46
- var webchannel = null; // WebRTC data channel
47
- var webrtcconfiguration = null; //{ "iceServers": [ { 'urls': 'stun:stun.services.mozilla.com' }, { 'urls': 'stun:stun.l.google.com:19302' } ] };
42
+ var socket = null; // Websocket object
43
+ var state = 0; // Connection state. 0 = Disconnected, 1 = Connecting, 2 = Connected.
44
+ var random = Math.random(); // Selected random, larger value initiates WebRTC.
45
+ var webrtcSessions = { }; // WebRTC objects: 0 for data, 1 for outbound audio/video, 2 for inbound audio/video
46
+ var webchannel = null; // WebRTC data channel
47
+ var webrtcconfiguration = null; //{ "iceServers": [ { 'urls': 'stun:stun.services.mozilla.com' }, { 'urls': 'stun:stun.l.google.com:19302' } ] };
48
var localStream = null;
49
+ var remoteStream = null;
50
+ var senders = null;
51
52
// Set the title
53
if (args.title) { QH('xtitle', ' - ' + args.title); document.title = document.title + ' - ' + args.title; }
54
53
- // Trap document key presses
54
- document.onkeypress = function ondockeypress(e) {
55
- if (controlsEnabled == true) {
55
+ document.onkeyup = function ondockeypress(e) {
56
+ if (state == 2) {
57
if ((e.keyCode == 8) && (userInputFocus == 0)) {
58
// Backspace
59
var outtext = Q('xouttext').value;
60
if (outtext.length > 0) { Q('xouttext').value = outtext.substring(0, outtext.length - 1); }
60
- } else if (e.keyCode == 13) {
61
+ }
62
+ }
63
+ if (userInputFocus == 0) { haltEvent(e); return false; }
64
+ }
65
+
66
+ // Trap document key presses
67
+ document.onkeypress = function ondockeypress(e) {
68
+ if (state == 2) {
69
+ if (e.keyCode == 13) {
70
// Return
71
xsend(e);
72
} else {
@@ -77,12 +86,17 @@
86
Q('xmsg').scrollTop = Q('xmsg').scrollHeight;
87
}
88
80
- function displayLocalVideo(active) { QV('localVideo', active); }
81
- function displayRemoteVideo(active) { QV('remoteVideo', active); }
89
+ function displayLocalVideo(active) { QV('localVideo', active); adjustVideoWindows(); }
90
+ function displayRemoteVideo(active) { QV('remoteVideo', active); adjustVideoWindows(); }
91
+ function adjustVideoWindows() {
92
+ //var lv = (QS('localVideo')['display'] != 'none');
93
+ var rv = (QS('remoteVideo')['display'] != 'none');
94
+ QS('localVideo')['top'] = rv ? '320px' : '45px';
95
+ }
96
97
// Display a message from the remote user
98
function displayRemote(msg) {
85
- QA('xmsg', '<div style="clear:both"><div style="background-color:#00cc99;color:black;border-radius:5px;padding:5px;float:left;margin-bottom:5px;margin-right:20px">' + msg + '</div><div></div></div>');
99
+ QA('xmsg', '<div style="clear:both"><div class="remoteBubble">' + msg + '</div><div></div></div>');
100
Q('xmsg').scrollTop = Q('xmsg').scrollHeight;
101
}
102
@@ -91,51 +105,84 @@
105
var outtext = Q('xouttext').value;
106
if (outtext.length > 0) {
107
Q('xouttext').value = '';
94
- QA('xmsg', '<div style="clear:both"><div style="background-color:#0099ff;color:black;border-radius:5px;padding:5px;float:right;margin-bottom:5px;margin-left:20px">' + outtext + '</div><div></div></div>');
108
+ QA('xmsg', '<div style="clear:both"><div class="localBubble">' + outtext + '</div><div></div></div>');
109
Q('xmsg').scrollTop = Q('xmsg').scrollHeight;
110
send({ action: 'chat', msg: outtext });
111
}
112
}
113
100
- // Enable user controls
101
- function enableControls(lock, webrtc) { controlsEnabled = lock; QE('sendButton', lock); QE('clearButton', lock); QE('xouttext', lock); /*QV('fileButton', lock);*/ QV('camButton', lock & webrtc); QV('micButton', lock & webrtc); }
114
function haltEvent(e) { if (e.preventDefault) e.preventDefault(); if (e.stopPropagation) e.stopPropagation(); return false; }
115
function parseUriArgs() { var name, r = {}, parsedUri = window.document.location.href.split(/[\?&|\=]/); parsedUri.splice(0, 1); for (x in parsedUri) { switch (x % 2) { case 0: { name = parsedUri[x]; break; } case 1: { r[name] = parsedUri[x]; var x = parseInt(r[name]); if (x == r[name]) { r[name] = x; } break; } } } return r; }
116
117
+ // Update user controls
118
+ function updateControls() {
119
+ QE('sendButton', state == 2);
120
+ QE('clearButton', state == 2);
121
+ QE('xouttext', state == 2);
122
+ QV('camButton', webchannel && webchannel.ok && !localStream);
123
+ QV('micButton', webchannel && webchannel.ok && !localStream);
124
+ QV('hangupButton', webchannel && webchannel.ok && localStream);
125
+ }
126
+
127
// This is the WebRTC setup
106
- function startWebRTC(description) {
128
+ function startWebRTC(id, startDataChannel) {
129
// Setup the WebRTC object
108
- if (webrtc == null) {
109
- if (typeof RTCPeerConnection !== 'undefined') { webrtc = new RTCPeerConnection(webrtcconfiguration); }
110
- else if (typeof webkitRTCPeerConnection !== 'undefined') { webrtc = new webkitRTCPeerConnection(webrtcconfiguration); }
111
- if (webrtc == null) return;
112
- webrtc.onicecandidate = function (e) { try { if (e.candidate != null) { sendws({ action: 'webRtcIce', ice: e.candidate }); } } catch (ex) { } }
113
- webrtc.oniceconnectionstatechange = function () { if (webrtc && webrtc.iceConnectionState == 'failed') { closeWebRTC(); } }
114
- webrtc.ondatachannel = function (ev) {
115
- webchannel = ev.channel;
116
- webchannel.onmessage = function (event) { processMessage(event.data, 2); };
117
- webchannel.onopen = function () { webchannel.ok = true; enableControls(true, true); sendws({ action: 'rtcSwitch', v: 0 }); };
118
- webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { closeWebRTC(); } }
119
- }
130
+ var webrtc;
131
+ if (typeof RTCPeerConnection !== 'undefined') { webrtc = new RTCPeerConnection(webrtcconfiguration); }
132
+ else if (typeof webkitRTCPeerConnection !== 'undefined') { webrtc = new webkitRTCPeerConnection(webrtcconfiguration); }
133
+ webrtc.id = id;
134
+ webrtc.onicecandidate = function (e) { try { if (e.candidate != null) { sendws({ action: 'webRtcIce', ice: e.candidate, id: this.id }); } } catch (ex) { } }
135
+ webrtc.oniceconnectionstatechange = function () { if (webrtc && webrtc.iceConnectionState == 'failed') { hangUpButtonClick(webrtc.id); } }
136
+ webrtc.ondatachannel = function (ev) {
137
+ webchannel = ev.channel;
138
+ webchannel.onmessage = function (event) { processMessage(event.data, 2); };
139
+ webchannel.onopen = function () { webchannel.ok = true; updateControls(); sendws({ action: 'rtcSwitch', v: 0 }); };
140
+ webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { hangUpButtonClick(0); } }
141
+ }
142
+ webrtc.onnegotiationneeded = function (event) {
143
+ if (webrtc.holdTimer != null) return;
144
+ webrtc.holdTimer = setTimeout(function () { // This time is needed to keep Chrome from being to excited. Wait until we add all tracks before kicking this off.
145
+ //console.log('onnegotiationneeded', id);
146
+ webrtc.holdTimer = null;
147
+ webrtc.createOffer(function (offer) { webrtc.setLocalDescription(offer, function () { sendws({ action: 'webRtcSdp', sdp: offer, id: id }); }, function () { hangUpButtonClick(id); }); }, function () { hangUpButtonClick(id); });
148
+ }, 20);
149
+ }
150
+ webrtc.ontrack = function (event) {
151
+ //console.log('ontrack', id);
152
+ var video = Q('remoteVideoCanvas');
153
+ video.srcObject = remoteStream = event.streams[0];
154
+ video.onloadedmetadata = function (e) { video.play(); };
155
+ displayRemoteVideo(true);
156
}
157
+ //webrtc.onremovetrack = function (event) { console.log('onremovetrack'); }
158
+ //webrtc.onicegatheringstatechange = function (event) { console.log('onicegatheringstatechange', event); }
159
+ //webrtc.onsignalingstatechange = function (event) { console.log('onsignalingstatechange', event); }
160
161
// Initiate the WebRTC offer or handle the offer from the peer.
123
- if (description == null) {
162
+ if (startDataChannel == true) {
163
webchannel = webrtc.createDataChannel("DataChannel", {}); // { ordered: false, maxRetransmits: 2 }
164
webchannel.onmessage = function (event) { processMessage(event.data, 2); };
126
- webchannel.onopen = function () { webchannel.ok = true; enableControls(true, true); sendws({ action: 'rtcSwitch', v: 0 }); };
127
- webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { closeWebRTC(); } }
128
- webrtc.createOffer(function (offer) {
129
- webrtc.setLocalDescription(offer, function () { try { sendws({ action: 'webRtcSdp', sdp: offer }); } catch (ex) { } }, closeWebRTC);
130
- }, closeWebRTC, { mandatory: { OfferToReceiveAudio: false, OfferToReceiveVideo: false } });
131
- } else {
165
+ webchannel.onopen = function () { webchannel.ok = true; updateControls(); sendws({ action: 'rtcSwitch', v: 0 }); };
166
+ webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { hangUpButtonClick(0); } }
167
+ }
168
+
169
+ webrtcSessions[id] = webrtc;
170
+ return webrtc;
171
+ }
172
+
173
+ function webRtcHandleOffer(id, description) {
174
+ //console.log('webRtcHandleOffer', description.sdp.length);
175
+ var webrtc = webrtcSessions[id];
176
+ if (webrtc) {
177
webrtc.setRemoteDescription(new RTCSessionDescription(description), function () {
178
if (description.type == 'offer') {
179
webrtc.createAnswer(function (answer) {
135
- webrtc.setLocalDescription(answer, function () { try { sendws({ action: 'webRtcSdp', sdp: answer }); } catch (ex) { } }, closeWebRTC);
136
- }, closeWebRTC);
180
+ webrtc.setLocalDescription(answer, function (a, b) {
181
+ try { sendws({ action: 'webRtcSdp', sdp: answer, id: id }); } catch (ex) { }
182
+ }, function () { hangUpButtonClick(id); });
183
+ }, function () { hangUpButtonClick(id); });
184
}
138
- }, closeWebRTC);
185
+ }, function () { hangUpButtonClick(id); });
186
}
187
}
188
@@ -144,21 +191,15 @@
191
if (webchannel && webchannel.ok) { sendws({ action: 'rtcSwitch', v: 1 }); webchannel.xoutBuffer = []; }
192
}
193
147
- // Close the WebRTC connection, should be called if a problem occurs during WebRTC setup.
148
- function closeWebRTC() {
149
- if (webchannel != null) { try { webchannel.close(); } catch (e) { } webchannel = null; }
150
- if (webrtc != null) { try { webrtc.close(); } catch (e) { } webrtc = null; }
151
- }
152
-
194
// Disconnect everything
195
function disconnect() {
155
- enableControls(false);
156
- closeWebRTC();
157
- displayLocalVideo(false);
158
- displayRemoteVideo(false);
159
- if (socket != null) { socket.close(); socket = null; }
196
if (state > 0) { displayControl('Connection closed.'); }
197
if (state > 1) { setTimeout(start, 500); }
198
+ hangUpButtonClick(0, true); // Data channel
199
+ hangUpButtonClick(1, true); // Local audio/video
200
+ hangUpButtonClick(2, true); // Remote audio/video
201
+ if (socket != null) { socket.close(); socket = null; }
202
+ updateControls();
203
state = 0;
204
}
205
@@ -177,15 +218,20 @@
218
if (socket != null) { socket.send(data); }
219
}
220
221
+ // WebRTC id switcher (0 -> 0, 1 -> 2, 2 -> 1)
222
+ function webRtcIdSwitch(id) { if (id == 0) { return 0; } return 3 - id; }
223
+
224
// Process incoming messages
225
function processMessage(data, transport) {
226
if (typeof data == 'string') {
227
try { data = JSON.parse(data); } catch (ex) { console.log('Unable to parse', data); return; }
228
+ //console.log(data);
229
switch (data.action) {
230
case 'chat': { displayRemote(data.msg); break; } // Incoming chat message.
186
- case 'random': { if (random > data.random) { startWebRTC(); } break; } // If we have a larger random value, we start WebRTC.
187
- case 'webRtcSdp': { startWebRTC(data.sdp); break; } // Remote WebRTC offer or answer.
188
- case 'webRtcIce': { if (webrtc) { webrtc.addIceCandidate(new RTCIceCandidate(data.ice)); } break; } // Remote ICE candidate
231
+ case 'random': { if (random > data.random) { startWebRTC(0, true); } break; } // If we have a larger random value, we start WebRTC.
232
+ case 'webRtcSdp': { if (!webrtcSessions[webRtcIdSwitch(data.id)]) { startWebRTC(webRtcIdSwitch(data.id), false); } webRtcHandleOffer(webRtcIdSwitch(data.id), data.sdp); break; } // Remote WebRTC offer or answer.
233
+ case 'webRtcIce': { var webrtc = webrtcSessions[webRtcIdSwitch(data.id)]; if (webrtc) { try { webrtc.addIceCandidate(new RTCIceCandidate(data.ice)); } catch (ex) { } } break; } // Remote ICE candidate
234
+ case 'videoStop': { hangUpButtonClick(webRtcIdSwitch(data.id), true); break; }
235
case 'rtcSwitch': { // WebRTC switch over commands.
236
switch (data.v) {
237
case 0: { performWebRtcSwitch(); break; } // Other side is ready for switch over to WebRTC
@@ -208,60 +254,95 @@
254
255
// Camera button
256
function camButtonClick() {
211
- if (localStream == null) { startLocalStream({ video: true, audio: true }); } else { stopLocalStream(); }
257
+ if (localStream == null) { startLocalStream({ video: true, audio: true }); }
258
}
259
260
// Microphone
261
function micButtonClick() {
216
- if (localStream == null) { startLocalStream({ video: false, audio: true }); } else { stopLocalStream(); }
262
+ if (localStream == null) { startLocalStream({ video: false, audio: true }); }
263
+ }
264
+
265
+ function hangUpButtonClick(id, fromRemote) {
266
+ //console.log('hangUpButtonClick', id);
267
+ var localVideo = Q('localVideoCanvas');
268
+ var remoteVideo = Q('remoteVideoCanvas');
269
+ var webrtc = webrtcSessions[1];
270
+
271
+ if ((id == 0) && (webchannel != null)) { try { webchannel.close(); } catch (e) { } webchannel = null; }
272
+
273
+ if (webrtc) {
274
+ webrtc.ontrack = null;
275
+ webrtc.onremovetrack = null;
276
+ webrtc.onremovestream = null;
277
+ webrtc.onnicecandidate = null;
278
+ webrtc.oniceconnectionstatechange = null;
279
+ webrtc.onsignalingstatechange = null;
280
+ webrtc.onicegatheringstatechange = null;
281
+ webrtc.onnotificationneeded = null;
282
+
283
+ if ((id == 1) && localVideo.srcObject) { localVideo.srcObject.getTracks().forEach(track => track.stop()); }
284
+ if ((id == 2) && remoteVideo.srcObject) { remoteVideo.srcObject.getTracks().forEach(track => track.stop()); }
285
+
286
+ webrtc.close();
287
+ delete webrtcSessions[id];
288
+ }
289
+
290
+ if (id == 1) {
291
+ localVideo.removeAttribute("src");
292
+ localVideo.removeAttribute("srcObject");
293
+ if (localStream != null) { localStream = null; }
294
+ displayLocalVideo(false);
295
+ } else if (id == 2) {
296
+ remoteVideo.removeAttribute("src");
297
+ remoteVideo.removeAttribute("srcObject");
298
+ displayRemoteVideo(false);
299
+ }
300
+
301
+ if (fromRemote != true) { send({ action: 'videoStop', id: id }); }
302
+ updateControls();
303
}
304
305
// Setup local audio/video
306
function startLocalStream(constraints) {
221
- if (localStream != null) return;
307
+ if ((localStream != null) || (webrtcSessions[1] != null)) return;
308
if (navigator.mediaDevices.getUserMedia) {
309
localStream = 1;
310
+ updateControls();
311
navigator.mediaDevices.getUserMedia(constraints)
312
.then(function (stream) {
313
localStream = stream;
314
if (constraints.video == true) {
228
- var video = Q('localVideoCanvas');
315
+ var video = Q('localVideoCanvas'), tracks = localStream.getTracks(), webrtc = startWebRTC(1);
316
video.srcObject = stream;
317
video.onloadedmetadata = function (e) { video.play(); };
318
displayLocalVideo(true);
319
+ for (var i in tracks) { webrtc.addTrack(tracks[i], localStream); }
320
}
321
})
322
.catch(function (err) {
235
- console.log('getUserMedia error');
236
- localStream = null;
323
+ displayControl(err.message + '.');
324
+ hangUpButtonClick(1);
325
});
326
}
327
}
328
241
- // Stop local audio/video
242
- function stopLocalStream() {
243
- if ((localStream != null) && (localStream != 1)) {
244
- localStream.getTracks().forEach(track => track.stop());
245
- localStream = null;
246
- displayLocalVideo(false);
247
- }
248
- }
249
-
329
// This is the main start
330
function start() {
331
// Get started
253
- enableControls(false);
332
+ updateControls();
333
if ((typeof args.id == 'string') && (args.id.length > 0)) {
334
socket = new WebSocket(window.location.protocol.replace("http", "ws") + "//" + window.location.host + window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')) + '/meshrelay.ashx?id=' + args.id);
335
socket.onopen = function () { state = 1; displayControl('Waiting for other user...'); }
336
socket.onerror = function (e) { console.error(e); }
337
socket.onclose = function () { disconnect(); }
338
socket.onmessage = function (msg) {
260
- if ((state < 2) && (typeof msg.data == 'string')) {
261
- enableControls(true);
262
- closeWebRTC();
339
+ if ((state < 2) && (typeof msg.data == 'string') && (msg.data == 'c')) {
340
+ hangUpButtonClick(0);
341
+ hangUpButtonClick(1);
342
+ hangUpButtonClick(2);
343
displayControl('Connected.');
344
state = 2;
345
+ updateControls();
346
sendws({ action: 'random', random: random }); // Send a random number. Higher number starts the WebRTC session.
347
return;
348
}
public/styles/messenger.css
+29
-1
@@ -12,6 +12,29 @@
12
background-color: lightgray;
13
}
14
15
+
16
+.remoteBubble {
17
+ background-color: #00cc99;
18
+ color: black;
19
+ border-radius: 5px;
20
+ padding: 5px;
21
+ float: left;
22
+ margin-bottom: 5px;
23
+ margin-right: 20px;
24
+ box-shadow:3px 3px 10px gray;
25
+}
26
+
27
+.localBubble {
28
+ background-color: #0099ff;
29
+ color: black;
30
+ border-radius: 5px;
31
+ padding: 5px;
32
+ float: right;
33
+ margin-bottom: 5px;
34
+ margin-left: 20px;
35
+ box-shadow:3px 3px 10px gray;
36
+}
37
+
38
.icon1 {
39
background: url(../images/messenger32.png) 0px 0px;
40
background-color: gray;
@@ -60,4 +83,9 @@
83
.icon10 {
84
background: url(../images/messenger32.png) -288px 0px;
85
background-color: gray;
63
-}
\ No newline at end of file
86
+}
87
+
88
+.icon11 {
89
+ background: url(../images/messenger32.png) -320px 0px;
90
+ background-color: gray;
91
+}