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 +}