diff options
20 files changed, 513 insertions, 184 deletions
diff --git a/docs/client-server/OLD_specification.rst b/docs/client-server/OLD_specification.rst index 47fba5eeac..425ae57d93 100644 --- a/docs/client-server/OLD_specification.rst +++ b/docs/client-server/OLD_specification.rst @@ -4,7 +4,7 @@ Matrix Client-Server API .. WARNING:: - This specification is old. Please see /docs/specification.rst instead. + This specification is old. Please see matrix-doc/specification instead. diff --git a/docs/implementation-notes/architecture.rst b/docs/implementation-notes/architecture.rst new file mode 100644 index 0000000000..98050428b9 --- /dev/null +++ b/docs/implementation-notes/architecture.rst @@ -0,0 +1,68 @@ +Synapse Architecture +==================== + +As of the end of Oct 2014, Synapse's overall architecture looks like:: + + synapse + .-----------------------------------------------------. + | Notifier | + | ^ | | + | | | | + | .------------|------. | + | | handlers/ | | | + | | v | | + | | Event*Handler <--------> rest/* <=> Client + | | Rooms*Handler | | + HSes <=> federation/* <==> FederationHandler | | + | | | PresenceHandler | | + | | | TypingHandler | | + | | '-------------------' | + | | | | | + | | state/* | | + | | | | | + | | v v | + | `--------------> storage/* | + | | | + '--------------------------|--------------------------' + v + .----. + | DB | + '----' + +* Handlers: business logic of synapse itself. Follows a set contract of BaseHandler: + + - BaseHandler gives us onNewRoomEvent which: (TODO: flesh this out and make it less cryptic): + + + handle_state(event) + + auth(event) + + persist_event(event) + + notify notifier or federation(event) + + - PresenceHandler: use distributor to get EDUs out of Federation. Very + lightweight logic built on the distributor + - TypingHandler: use distributor to get EDUs out of Federation. Very + lightweight logic built on the distributor + - EventsHandler: handles the events stream... + - FederationHandler: - gets PDU from Federation Layer; turns into an event; + follows basehandler functionality. + - RoomsHandler: does all the room logic, including members - lots of classes in + RoomsHandler. + - ProfileHandler: talks to the storage to store/retrieve profile info. + +* EventFactory: generates events of particular event types. +* Notifier: Backs the events handler +* REST: Interfaces handlers and events to the outside world via HTTP/JSON. + Converts events back and forth from JSON. +* Federation: holds the HTTP client & server to talk to other servers. Does + replication to make sure there's nothing missing in the graph. Handles + reliability. Handles txns. +* Distributor: generic event bus. used for presence & typing only currently. + Notifier could be implemented using Distributor - so far we are only using for + things which actually /require/ dynamic pluggability however as it can + obfuscate the actual flow of control. +* Auth: helper singleton to say whether a given event is allowed to do a given + thing (TODO: put this on the diagram) +* State: helper singleton: does state conflict resolution. You give it an event + and it tells you if it actually updates the state or not, and annotates the + event up properly and handles merge conflict resolution. +* Storage: abstracts the storage engine. diff --git a/docs/implementation-notes/python_architecture.rst b/docs/implementation-notes/python_architecture.rst index 8beaa615d0..2a5a2613c4 100644 --- a/docs/implementation-notes/python_architecture.rst +++ b/docs/implementation-notes/python_architecture.rst @@ -1,3 +1,9 @@ +.. WARNING:: + These architecture notes are spectacularly old, and date back to when Synapse + was just federation code in isolation. This should be merged into the main + spec. + + = Server to Server = == Server to Server Stack == diff --git a/synapse/http/content_repository.py b/synapse/http/content_repository.py index 3159ffff0a..1306b35271 100644 --- a/synapse/http/content_repository.py +++ b/synapse/http/content_repository.py @@ -129,6 +129,14 @@ class ContentRepoResource(resource.Resource): logger.info("Sending file %s", file_path) f = open(file_path, 'rb') request.setHeader('Content-Type', content_type) + + # cache for at least a day. + # XXX: we might want to turn this off for data we don't want to recommend + # caching as it's sensitive or private - or at least select private. + # don't bother setting Expires as all our matrix clients are smart enough to + # be happy with Cache-Control (right?) + request.setHeader('Cache-Control', 'public,max-age=86400,s-maxage=86400') + d = FileSender().beginFileTransfer(f, request) # after the file has been sent, clean up and finish the request diff --git a/syweb/webclient/app-controller.js b/syweb/webclient/app-controller.js index 2d82a42cf8..bbcf4ab5f6 100644 --- a/syweb/webclient/app-controller.js +++ b/syweb/webclient/app-controller.js @@ -112,8 +112,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even if (!$rootScope.currentCall) { // This causes the still frame to be flushed out of the video elements, // avoiding a flash of the last frame of the previous call when starting the next - angular.element('#localVideo')[0].load(); - angular.element('#remoteVideo')[0].load(); + if (angular.element('#localVideo')[0].load) angular.element('#localVideo')[0].load(); + if (angular.element('#remoteVideo')[0].load) angular.element('#remoteVideo')[0].load(); return; } @@ -187,8 +187,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even } call.onError = $scope.onCallError; call.onHangup = $scope.onCallHangup; - call.localVideoElement = angular.element('#localVideo')[0]; - call.remoteVideoElement = angular.element('#remoteVideo')[0]; + call.localVideoSelector = '#localVideo'; + call.remoteVideoSelector = '#remoteVideo'; $rootScope.currentCall = call; }); diff --git a/syweb/webclient/app.css b/syweb/webclient/app.css index 5ab8e2b8fd..23ec42f128 100755 --- a/syweb/webclient/app.css +++ b/syweb/webclient/app.css @@ -136,17 +136,17 @@ textarea, input { transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms; } -#localVideo.mini { +.mini #localVideo { top: 0px; left: 130px; } -#localVideo.large { +.large #localVideo { top: 70px; left: 20px; } -#localVideo.ended { +.ended #localVideo { -webkit-filter: grayscale(1); filter: grayscale(1); } @@ -157,19 +157,19 @@ textarea, input { transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms; } -#remoteVideo.mini { +.mini #remoteVideo { left: 260px; top: 0px; width: 128px; } -#remoteVideo.large { +.large #remoteVideo { left: 0px; top: 50px; width: 100%; } -#remoteVideo.ended { +.ended #remoteVideo { -webkit-filter: grayscale(1); filter: grayscale(1); } @@ -318,7 +318,7 @@ textarea, input { position: absolute; bottom: 0px; width: 100%; - height: 100px; + height: 70px; background-color: #f8f8f8; border-top: #aaa 1px solid; } @@ -326,7 +326,9 @@ textarea, input { #controls { max-width: 1280px; padding: 12px; + padding-right: 42px; margin: auto; + position: relative; } #buttonsCell { @@ -343,7 +345,19 @@ textarea, input { #mainInput { width: 100%; - resize: none; + padding: 5px; + resize: vertical; +} + +#attachButton { + position: absolute; + cursor: pointer; + margin-top: 3px; + right: 0px; + background: url('img/attach.png'); + width: 25px; + height: 25px; + border: 0px; } .blink { @@ -415,7 +429,8 @@ textarea, input { .roomHeaderInfo { text-align: right; float: right; - margin-top: 15px; + margin-top: 0px; + margin-right: 30px; } /*** Room Info Dialog ***/ @@ -449,15 +464,33 @@ textarea, input { resize: vertical; } +/*** Control Buttons ***/ +#controlButtons { + float: right; + margin-right: -4px; + padding-bottom: 6px; +} + +.controlButton { + cursor: pointer; + border: 0px; + width: 30px; + height: 30px; + margin-left: 3px; + margin-right: 3px; +} + /*** Participant list ***/ #usersTableWrapper { float: right; - width: 120px; + clear: right; + width: 100px; height: 100%; overflow-y: auto; } +/* #usersTable { width: 100%; border-collapse: collapse; @@ -473,36 +506,66 @@ textarea, input { position: relative; background-color: #000; } +*/ -.userAvatar .userAvatarImage { - position: absolute; - top: 0px; +.userAvatar { +} + +.userAvatarFrame { + border-radius: 46px; + width: 80px; + margin: auto; + position: relative; + border: 3px solid #aaa; + background-color: #aaa; +} + +.userAvatarImage { + border-radius: 40px; + text-align: center; object-fit: cover; - width: 100%; + display: block; } +/* .userAvatar .userAvatarGradient { position: absolute; bottom: 20px; width: 100%; } +*/ -.userAvatar .userName { - position: absolute; - color: #fff; - margin: 2px; - bottom: 0px; +.userName { + margin-top: 3px; + margin-bottom: 6px; + text-align: center; font-size: 12px; - word-break: break-all; + word-wrap: break-word; +} + +.userPowerLevel { + position: absolute; + bottom: -1px; + height: 1px; + background-color: #f00; } -.userAvatar .userPowerLevel { +.userPowerLevelBar { + display: inline; position: absolute; + width: 2px; + height: 10px; +/* border: 1px solid #000; +*/ background-color: #aaa; +} + +.userPowerLevelMeter { + position: relative; bottom: 0px; - height: 2px; background-color: #f00; } +/* .userPresence { text-align: center; font-size: 12px; @@ -510,12 +573,15 @@ textarea, input { background-color: #aaa; border-bottom: 1px #ddd solid; } +*/ .online { + border-color: #38AF00; background-color: #38AF00; } .unavailable { + border-color: #FFCC00; background-color: #FFCC00; } @@ -538,18 +604,21 @@ textarea, input { #messageTable td { padding: 0px; +/* border: 1px solid #888; */ } .leftBlock { - width: 14em; + width: 7em; word-wrap: break-word; vertical-align: top; background-color: #fff; - color: #888; + color: #aaa; font-weight: medium; font-size: 12px; text-align: right; +/* border-top: 1px #ddd solid; +*/ } .rightBlock { @@ -560,13 +629,24 @@ textarea, input { } .sender, .timestamp { - padding-right: 1em; - padding-left: 1em; - padding-top: 3px; +/* padding-top: 3px; +*/} + +.timestamp { + font-size: 10px; + color: #ccc; + height: 13px; + margin-top: 4px; +*/ transition-property: opacity; + transition-duration: 0.3s; } .sender { - margin-bottom: -3px; + font-size: 12px; +/* + margin-top: 5px; + margin-bottom: -9px; +*/ } .avatar { @@ -577,7 +657,11 @@ textarea, input { } .avatarImage { + position: relative; + top: 5px; object-fit: cover; + border-radius: 32px; + margin-top: 4px; } .emote { @@ -591,6 +675,7 @@ textarea, input { } .image { + border: 1px solid #888; display: block; max-width:320px; max-height:320px; @@ -603,19 +688,23 @@ textarea, input { } .bubble { +/* background-color: #eee; border: 1px solid #d8d8d8; - display: inline-block; margin-bottom: -1px; - max-width: 90%; - font-size: 14px; - word-wrap: break-word; padding-top: 7px; padding-bottom: 5px; + -webkit-text-size-adjust:100% + vertical-align: middle; +*/ + display: inline-block; + max-width: 90%; padding-left: 1em; padding-right: 1em; - vertical-align: middle; - -webkit-text-size-adjust:100% + padding-top: 2px; + padding-bottom: 2px; + font-size: 14px; + word-wrap: break-word; } .bubble img { @@ -623,8 +712,8 @@ textarea, input { max-height: auto; } -.differentUser td { - padding-bottom: 5px ! important; +.differentUser .msg { + padding-top: 14px ! important; } .mine { @@ -635,13 +724,15 @@ textarea, input { .text.membership .bubble, .mine .text.emote .bubble, .mine .text.membership .bubble - { +{ background-color: transparent ! important; border: 0px ! important; } .mine .text .bubble { +/* background-color: #f8f8ff ! important; +*/ text-align: left ! important; } @@ -701,6 +792,8 @@ textarea, input { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + padding-left: 0.5em; + padding-right: 0.5em; } .recentsRoom { @@ -751,7 +844,7 @@ textarea, input { padding-right: 10px; margin-right: 10px; height: 100%; - border-right: 1px solid #ddd; +/* border-right: 1px solid #ddd; */ overflow-y: auto; } diff --git a/syweb/webclient/components/matrix/event-handler-service.js b/syweb/webclient/components/matrix/event-handler-service.js index a9c6eb34c7..f51031f4cd 100644 --- a/syweb/webclient/components/matrix/event-handler-service.js +++ b/syweb/webclient/components/matrix/event-handler-service.js @@ -299,10 +299,12 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati * Return the display name of an user acccording to data already downloaded * @param {String} room_id the room id * @param {String} user_id the id of the user + * @param {boolean} wrap whether to insert whitespace into the userid (if displayname not available) to help it wrap * @returns {String} the user displayname or user_id if not available */ - var getUserDisplayName = function(room_id, user_id) { + var getUserDisplayName = function(room_id, user_id, wrap) { var displayName; + // XXX: this is getting called *way* too often - at least once per every room member per every digest... // Get the user display name from the member list of the room var member = modelService.getMember(room_id, user_id); @@ -336,8 +338,16 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati if (undefined === displayName) { // By default, use the user ID - displayName = user_id; + if (wrap && user_id.indexOf(':') >= 0) { + displayName = user_id.substr(0, user_id.indexOf(':')) + " " + user_id.substr(user_id.indexOf(':')); + } + else { + displayName = user_id; + } } + + //console.log("getUserDisplayName(" + room_id + ", " + user_id + ", " + wrap +") = " + displayName); + return displayName; }; @@ -589,10 +599,11 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati * Return the display name of an user acccording to data already downloaded * @param {String} room_id the room id * @param {String} user_id the id of the user + * @param {boolean} wrap whether to insert whitespace into the userid (if displayname not available) to help it wrap * @returns {String} the user displayname or user_id if not available */ - getUserDisplayName: function(room_id, user_id) { - return getUserDisplayName(room_id, user_id); + getUserDisplayName: function(room_id, user_id, wrap) { + return getUserDisplayName(room_id, user_id, wrap); } }; }]); diff --git a/syweb/webclient/components/matrix/matrix-call.js b/syweb/webclient/components/matrix/matrix-call.js index c13083298e..b560cf7daa 100644 --- a/syweb/webclient/components/matrix/matrix-call.js +++ b/syweb/webclient/components/matrix/matrix-call.js @@ -35,14 +35,14 @@ var forAllTracksOnStream = function(s, f) { forAllAudioTracksOnStream(s, f); } -navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; -window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible -window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription; -window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate; - angular.module('MatrixCall', []) .factory('MatrixCall', ['matrixService', 'matrixPhoneService', 'modelService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, modelService, $rootScope, $timeout) { $rootScope.isWebRTCSupported = function () { + navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; + window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible + window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription; + window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate; + return !!(navigator.getUserMedia || window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate); }; @@ -57,7 +57,7 @@ angular.module('MatrixCall', []) this.candidateSendTries = 0; var self = this; - $rootScope.$watch(this.remoteVideoElement, function (oldValue, newValue) { + $rootScope.$watch(this.getRemoteVideoElement(), function (oldValue, newValue) { self.tryPlayRemoteStream(); }); @@ -175,7 +175,8 @@ angular.module('MatrixCall', []) this.state = 'ringing'; this.direction = 'inbound'; - if (window.mozRTCPeerConnection) { + // This also applied to the Safari OpenWebRTC extension so let's just do this all the time at least for now + //if (window.mozRTCPeerConnection) { // firefox's RTCPeerConnection doesn't add streams until it starts getting media on them // so we need to figure out whether a video channel has been offered by ourselves. if (this.msg.offer.sdp.indexOf('m=video') > -1) { @@ -183,7 +184,7 @@ angular.module('MatrixCall', []) } else { this.type = 'voice'; } - } + //} var self = this; $timeout(function() { @@ -251,8 +252,8 @@ angular.module('MatrixCall', []) // pausing now keeps the last frame (ish) of the video call in the video element // rather than it just turning black straight away - if (this.remoteVideoElement) this.remoteVideoElement.pause(); - if (this.localVideoElement) this.localVideoElement.pause(); + if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause(); + if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause(); this.stopAllMedia(); if (this.peerConn) this.peerConn.close(); @@ -277,11 +278,18 @@ angular.module('MatrixCall', []) } if (this.state == 'ended') return; - if (this.localVideoElement && this.type == 'video') { + var videoEl = this.getLocalVideoElement(); + + if (videoEl && this.type == 'video') { var vidTrack = stream.getVideoTracks()[0]; - this.localVideoElement.src = URL.createObjectURL(stream); - this.localVideoElement.muted = true; - this.localVideoElement.play(); + videoEl.autoplay = true; + videoEl.src = URL.createObjectURL(stream); + videoEl.muted = true; + var self = this; + $timeout(function() { + var vel = self.getLocalVideoElement(); + if (vel.play) vel.play(); + }); } this.localAVStream = stream; @@ -305,11 +313,18 @@ angular.module('MatrixCall', []) MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { if (this.state == 'ended') return; - if (this.localVideoElement && this.type == 'video') { + var localVidEl = this.getLocalVideoElement(); + + if (localVidEl && this.type == 'video') { + localVidEl.autoplay = true; var vidTrack = stream.getVideoTracks()[0]; - this.localVideoElement.src = URL.createObjectURL(stream); - this.localVideoElement.muted = true; - this.localVideoElement.play(); + localVidEl.src = URL.createObjectURL(stream); + localVidEl.muted = true; + var self = this; + $timeout(function() { + var vel = self.getLocalVideoElement(); + if (vel.play) vel.play(); + }); } this.localAVStream = stream; @@ -338,11 +353,11 @@ angular.module('MatrixCall', []) } MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { - console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate); if (this.state == 'ended') { - console.log("Ignoring remote ICE candidate because call has ended"); + //console.log("Ignoring remote ICE candidate because call has ended"); return; } + console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate); this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {}); }; @@ -362,41 +377,46 @@ angular.module('MatrixCall', []) return; } - this.peerConn.setLocalDescription(description); - - var content = { - version: 0, - call_id: this.call_id, - offer: description, - lifetime: MatrixCall.CALL_TIMEOUT - }; - this.sendEventWithRetry('m.call.invite', content); - var self = this; - $timeout(function() { - if (self.state == 'invite_sent') { - self.hangup('invite_timeout'); - } - }, MatrixCall.CALL_TIMEOUT); + this.peerConn.setLocalDescription(description, function() { + var content = { + version: 0, + call_id: self.call_id, + // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint) to the description + // when setting it on the peerconnection. According to the spec it should only add ICE + // candidates. Any ICE candidates that have already been generated at this point will + // probably be sent both in the offer and separately. Ho hum. + offer: self.peerConn.localDescription, + lifetime: MatrixCall.CALL_TIMEOUT + }; + self.sendEventWithRetry('m.call.invite', content); + + $timeout(function() { + if (self.state == 'invite_sent') { + self.hangup('invite_timeout'); + } + }, MatrixCall.CALL_TIMEOUT); - $rootScope.$apply(function() { - self.state = 'invite_sent'; - }); + $rootScope.$apply(function() { + self.state = 'invite_sent'; + }); + }, function() { console.log("Error setting local description!"); }); }; MatrixCall.prototype.createdAnswer = function(description) { console.log("Created answer: "+description); - this.peerConn.setLocalDescription(description); - var content = { - version: 0, - call_id: this.call_id, - answer: description - }; - this.sendEventWithRetry('m.call.answer', content); var self = this; - $rootScope.$apply(function() { - self.state = 'connecting'; - }); + this.peerConn.setLocalDescription(description, function() { + var content = { + version: 0, + call_id: self.call_id, + answer: self.peerConn.localDescription + }; + self.sendEventWithRetry('m.call.answer', content); + $rootScope.$apply(function() { + self.state = 'connecting'; + }); + }, function() { console.log("Error setting local description!"); } ); }; MatrixCall.prototype.getLocalOfferFailed = function(error) { @@ -464,10 +484,15 @@ angular.module('MatrixCall', []) }; MatrixCall.prototype.tryPlayRemoteStream = function(event) { - if (this.remoteVideoElement && this.remoteAVStream) { - var player = this.remoteVideoElement; + if (this.getRemoteVideoElement() && this.remoteAVStream) { + var player = this.getRemoteVideoElement(); + player.autoplay = true; player.src = URL.createObjectURL(this.remoteAVStream); - player.play(); + var self = this; + $timeout(function() { + var vel = self.getRemoteVideoElement(); + if (vel.play) vel.play(); + }); } }; @@ -499,8 +524,8 @@ angular.module('MatrixCall', []) MatrixCall.prototype.onHangupReceived = function(msg) { console.log("Hangup received"); - if (this.remoteVideoElement) this.remoteVideoElement.pause(); - if (this.localVideoElement) this.localVideoElement.pause(); + if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause(); + if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause(); this.state = 'ended'; this.hangupParty = 'remote'; this.hangupReason = msg.reason; @@ -523,8 +548,8 @@ angular.module('MatrixCall', []) newCall.gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } - newCall.localVideoElement = this.localVideoElement; - newCall.remoteVideoElement = this.remoteVideoElement; + newCall.localVideoSelector = this.localVideoSelector; + newCall.remoteVideoSelector = this.remoteVideoSelector; this.successor = newCall; this.hangup(true); }; @@ -600,5 +625,21 @@ angular.module('MatrixCall', []) }, delayMs); }; + MatrixCall.prototype.getLocalVideoElement = function() { + if (this.localVideoSelector) { + var t = angular.element(this.localVideoSelector); + if (t.length) return t[0]; + } + return null; + }; + + MatrixCall.prototype.getRemoteVideoElement = function() { + if (this.remoteVideoSelector) { + var t = angular.element(this.remoteVideoSelector); + if (t.length) return t[0]; + } + return null; + }; + return MatrixCall; }]); diff --git a/syweb/webclient/components/matrix/matrix-filter.js b/syweb/webclient/components/matrix/matrix-filter.js index aeebedc784..69de97b055 100644 --- a/syweb/webclient/components/matrix/matrix-filter.js +++ b/syweb/webclient/components/matrix/matrix-filter.js @@ -114,7 +114,7 @@ function($rootScope, matrixService, eventHandlerService, modelService) { // Return the user display name .filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) { - return function(user_id, room_id) { - return eventHandlerService.getUserDisplayName(room_id, user_id); + return function(user_id, room_id, wrap) { + return eventHandlerService.getUserDisplayName(room_id, user_id, wrap); }; }]); diff --git a/syweb/webclient/img/attach.png b/syweb/webclient/img/attach.png new file mode 100644 index 0000000000..d95eabaf00 --- /dev/null +++ b/syweb/webclient/img/attach.png Binary files differdiff --git a/syweb/webclient/img/settings.png b/syweb/webclient/img/settings.png new file mode 100644 index 0000000000..ac99fe402b --- /dev/null +++ b/syweb/webclient/img/settings.png Binary files differdiff --git a/syweb/webclient/img/video.png b/syweb/webclient/img/video.png new file mode 100644 index 0000000000..e90afea0c1 --- /dev/null +++ b/syweb/webclient/img/video.png Binary files differdiff --git a/syweb/webclient/img/voice.png b/syweb/webclient/img/voice.png new file mode 100644 index 0000000000..fe464999c0 --- /dev/null +++ b/syweb/webclient/img/voice.png Binary files differdiff --git a/syweb/webclient/index.html b/syweb/webclient/index.html index 992e8d3377..f6487f381d 100644 --- a/syweb/webclient/index.html +++ b/syweb/webclient/index.html @@ -17,6 +17,8 @@ <script src="js/angular-route.min.js"></script> <script src="js/angular-sanitize.min.js"></script> <script src="js/angular-animate.min.js"></script> + <script src="js/jquery.peity.min.js"></script> + <script src="js/angular-peity.js"></script> <script type='text/javascript' src="js/ui-bootstrap-tpls-0.11.2.js"></script> <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script> <script type='text/javascript' src='js/autofill-event.js'></script> @@ -53,8 +55,8 @@ <div id="videoBackground" ng-class="videoMode"> <div id="videoContainer" ng-class="videoMode"> <div id="videoContainerPadding"></div> - <video id="localVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"></video> - <video id="remoteVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"></video> + <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"><video id="localVideo"></video></div> + <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"><video id="remoteVideo"></video></div> </div> </div> diff --git a/syweb/webclient/js/angular-peity.js b/syweb/webclient/js/angular-peity.js new file mode 100644 index 0000000000..2acb647d91 --- /dev/null +++ b/syweb/webclient/js/angular-peity.js @@ -0,0 +1,69 @@ +var angularPeity = angular.module( 'angular-peity', [] ); + +$.fn.peity.defaults.pie = { + fill: ["#ff0000", "#aaaaaa"], + radius: 4, +} + +var buildChartDirective = function ( chartType ) { + return { + restrict: 'E', + scope: { + data: "=", + options: "=" + }, + link: function ( scope, element, attrs ) { + + var options = {}; + if ( scope.options ) { + options = scope.options; + } + + // N.B. live-binding to data by Matthew + scope.$watch('data', function () { + var span = document.createElement( 'span' ); + span.textContent = scope.data.join(); + + if ( !attrs.class ) { + span.className = ""; + } else { + span.className = attrs.class; + } + + if (element[0].nodeType === 8) { + element.replaceWith( span ); + } + else if (element[0].firstChild) { + element.empty(); + element[0].appendChild( span ); + } + else { + element[0].appendChild( span ); + } + + jQuery( span ).peity( chartType, options ); + }); + } + }; +}; + + +angularPeity.directive( 'pieChart', function () { + + return buildChartDirective( "pie" ); + +} ); + + +angularPeity.directive( 'barChart', function () { + + return buildChartDirective( "bar" ); + +} ); + + +angularPeity.directive( 'lineChart', function () { + + return buildChartDirective( "line" ); + +} ); diff --git a/syweb/webclient/js/jquery.peity.min.js b/syweb/webclient/js/jquery.peity.min.js new file mode 100644 index 0000000000..054b83c5d8 --- /dev/null +++ b/syweb/webclient/js/jquery.peity.min.js @@ -0,0 +1,13 @@ +// Peity jQuery plugin version 3.0.2 +// (c) 2014 Ben Pickles +// +// http://benpickles.github.io/peity +// +// Released under MIT license. +(function(h,w,i,v){var p=function(a,b){var d=w.createElementNS("http://www.w3.org/2000/svg",a);h(d).attr(b);return d},y="createElementNS"in w&&p("svg",{}).createSVGRect,e=h.fn.peity=function(a,b){y&&this.each(function(){var d=h(this),c=d.data("peity");c?(a&&(c.type=a),h.extend(c.opts,b)):(c=new x(d,a,h.extend({},e.defaults[a],b)),d.change(function(){c.draw()}).data("peity",c));c.draw()});return this},x=function(a,b,d){this.$el=a;this.type=b;this.opts=d},r=x.prototype;r.draw=function(){e.graphers[this.type].call(this, +this.opts)};r.fill=function(){var a=this.opts.fill;return h.isFunction(a)?a:function(b,d){return a[d%a.length]}};r.prepare=function(a,b){this.svg||this.$el.hide().after(this.svg=p("svg",{"class":"peity"}));return h(this.svg).empty().data("peity",this).attr({height:b,width:a})};r.values=function(){return h.map(this.$el.text().split(this.opts.delimiter),function(a){return parseFloat(a)})};e.defaults={};e.graphers={};e.register=function(a,b,d){this.defaults[a]=b;this.graphers[a]=d};e.register("pie", +{fill:["#ff9900","#fff4dd","#ffc66e"],radius:8},function(a){if(!a.delimiter){var b=this.$el.text().match(/[^0-9\.]/);a.delimiter=b?b[0]:","}b=this.values();if("/"==a.delimiter)var d=b[0],b=[d,i.max(0,b[1]-d)];for(var c=0,d=b.length,n=0;c<d;c++)n+=b[c];for(var c=2*a.radius,f=this.prepare(a.width||c,a.height||c),c=f.width(),f=f.height(),s=c/2,k=f/2,f=i.min(s,k),a=a.innerRadius,e=i.PI,q=this.fill(),g=this.scale=function(a,b){var c=2*a/n*e-e/2;return[b*i.cos(c)+s,b*i.sin(c)+k]},l=0,c=0;c<d;c++){var t= +b[c],j=t/n;if(0!=j){if(1==j)if(a)var j=s-0.01,o=k-f,m=k-a,j=p("path",{d:["M",s,o,"A",f,f,0,1,1,j,o,"L",j,m,"A",a,a,0,1,0,s,m].join(" ")});else j=p("circle",{cx:s,cy:k,r:f});else o=l+t,m=["M"].concat(g(l,f),"A",f,f,0,0.5<j?1:0,1,g(o,f),"L"),a?m=m.concat(g(o,a),"A",a,a,0,0.5<j?1:0,0,g(l,a)):m.push(s,k),l+=t,j=p("path",{d:m.join(" ")});h(j).attr("fill",q.call(this,t,c,b));this.svg.appendChild(j)}}});e.register("donut",h.extend(!0,{},e.defaults.pie),function(a){a.innerRadius||(a.innerRadius=0.5*a.radius); +e.graphers.pie.call(this,a)});e.register("line",{delimiter:",",fill:"#c6d9fd",height:16,min:0,stroke:"#4d89f9",strokeWidth:1,width:32},function(a){var b=this.values();1==b.length&&b.push(b[0]);for(var d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),n=this.prepare(a.width,a.height),f=a.strokeWidth,e=n.width(),k=n.height()-f,h=d-c,d=this.x=function(a){return a*(e/(b.length-1))},n=this.y=function(a){var b=k;h&&(b-=(a-c)/h*k);return b+f/2},q=n(i.max(c,0)),g=[0, +q],l=0;l<b.length;l++)g.push(d(l),n(b[l]));g.push(e,q);this.svg.appendChild(p("polygon",{fill:a.fill,points:g.join(" ")}));f&&this.svg.appendChild(p("polyline",{fill:"transparent",points:g.slice(2,g.length-2).join(" "),stroke:a.stroke,"stroke-width":f,"stroke-linecap":"square"}))});e.register("bar",{delimiter:",",fill:["#4D89F9"],height:16,min:0,padding:0.1,width:32},function(a){for(var b=this.values(),d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),e=this.prepare(a.width, +a.height),f=e.width(),h=e.height(),k=d-c,a=a.padding,e=this.fill(),r=this.x=function(a){return a*f/b.length},q=this.y=function(a){return h-(k?(a-c)/k*h:1)},g=0;g<b.length;g++){var l=r(g+a),t=r(g+1-a)-l,j=b[g],o=q(j),m=o,u;k?0>j?m=q(i.min(d,0)):o=q(i.max(c,0)):u=1;u=o-m;0==u&&(u=1,0<d&&k&&m--);this.svg.appendChild(p("rect",{fill:e.call(this,j,g,b),x:l,y:m,width:t,height:u}))}})})(jQuery,document,Math); diff --git a/syweb/webclient/mobile.css b/syweb/webclient/mobile.css index 6fa9221ccf..32b01c503d 100644 --- a/syweb/webclient/mobile.css +++ b/syweb/webclient/mobile.css @@ -1,4 +1,13 @@ /*** Mobile voodoo ***/ + +/** iPads **/ +@media all and (max-device-width: 768px) { + #roomRecentsTableWrapper { + display: none; + } +} + +/** iPhones **/ @media all and (max-device-width: 640px) { #messageTableWrapper { @@ -37,11 +46,16 @@ max-width: 640px ! important; } + #controls { + padding: 0px; + } + #headerUserId, #roomHeader img, #userIdCell, #roomRecentsTableWrapper, #usersTableWrapper, + #controlButtons, .extraControls { display: none; } @@ -64,6 +78,10 @@ padding-top: 10px; } + .roomHeaderInfo { + margin-right: 0px; + } + #roomName { font-size: 12px ! important; margin-top: 0px ! important; diff --git a/syweb/webclient/room/room-controller.js b/syweb/webclient/room/room-controller.js index d3fb85b9dc..be433d6e80 100644 --- a/syweb/webclient/room/room-controller.js +++ b/syweb/webclient/room/room-controller.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) +angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput', 'angular-peity']) .controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'notificationService', 'modelService', function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, notificationService, modelService) { 'use strict'; @@ -905,7 +905,20 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) paginate(MESSAGES_PER_PAGINATION); }; - $scope.startVoiceCall = function() { + $scope.checkWebRTC = function() { + if (!$rootScope.isWebRTCSupported()) { + alert("Your browser does not support WebRTC"); + return false; + } + if ($scope.memberCount() != 2) { + alert("WebRTC calls are currently only supported on rooms with two members"); + return false; + } + return true; + }; + + $scope.startVoiceCall = function() { + if (!$scope.checkWebRTC()) return; var call = new MatrixCall($scope.room_id); call.onError = $rootScope.onCallError; call.onHangup = $rootScope.onCallHangup; @@ -916,11 +929,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) }; $scope.startVideoCall = function() { + if (!$scope.checkWebRTC()) return; + var call = new MatrixCall($scope.room_id); call.onError = $rootScope.onCallError; call.onHangup = $rootScope.onCallHangup; - call.localVideoElement = angular.element('#localVideo')[0]; - call.remoteVideoElement = angular.element('#remoteVideo')[0]; + call.localVideoSelector = '#localVideo'; + call.remoteVideoSelector = '#remoteVideo'; call.placeVideoCall(); $rootScope.currentCall = call; }; diff --git a/syweb/webclient/room/room.html b/syweb/webclient/room/room.html index e59cc30edc..430a37afd4 100644 --- a/syweb/webclient/room/room.html +++ b/syweb/webclient/room/room.html @@ -15,6 +15,15 @@ <script type="text/ng-template" id="roomInfoTemplate.html"> <div class="modal-body"> + <span> + Invite a user: + <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/> + <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button> + </span> + <br/> + <br/> + <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave Room</button> + </br/> <table class="room-info"> <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event"> <td class="room-info-event-meta" width="30%"> @@ -57,6 +66,26 @@ <div id="roomHeader"> <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a> + + <div id="controlButtons"> + <button ng-click="startVoiceCall()" class="controlButton" + style="background: url('img/voice.png')" + ng-show="(currentCall == undefined || currentCall.state == 'ended')" + ng-disabled="state.permission_denied" + > + </button> + <button ng-click="startVideoCall()" class="controlButton" + style="background: url('img/video.png')" + ng-show="(currentCall == undefined || currentCall.state == 'ended')" + ng-disabled="state.permission_denied" + > + </button> + <button ng-click="openRoomInfo()" class="controlButton" + style="background: url('img/settings.png')" + > + </button> + </div> + <div class="roomHeaderInfo"> <div class="roomNameSection"> @@ -74,8 +103,8 @@ Set Topic </button> <div ng-show="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing"> - <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic"> - {{ room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200}} + <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic" + ng-bind-html="room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200 | linky:'_blank'"> </div> <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm"> <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" placeholder="Topic"/> @@ -91,32 +120,24 @@ <div id="roomRecentsTableWrapper"> <div ng-include="'recents/recents.html'"></div> </div> - + <div id="usersTableWrapper" ng-hide="state.permission_denied"> - <table id="usersTable"> - <tr ng-repeat="member in members | orderMembersList"> - <td class="userAvatar mouse-pointer" ng-click="$parent.goToUserPage(member.id)" ng-class="member.membership == 'invite' ? 'invited' : ''"> - <img class="userAvatarImage" - ng-src="{{member.avatar_url || 'img/default-profile.png'}}" - alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}" - title="{{ member.id }} - power: {{ member.powerLevel }}" - width="80" height="80"/> - <img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/> - <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div> - <div class="userName"> - <div ng-show="member.displayname"> - {{ member.id | mUserDisplayName: room_id }} - </div> - <div ng-hide="member.displayname"> - {{ member.id.substr(0, member.id.indexOf(':')) }}<br/> - {{ member.id.substr(member.id.indexOf(':')) }} - </div> - </div> - </td> - <td class="userPresence" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')"> - <span ng-show="member.last_active_ago">{{ member.last_active_ago + (now - member.last_updated) | duration }}<br/>ago</span> - </td> - </table> + <div ng-repeat="member in members | orderMembersList" class="userAvatar"> + <div class="userAvatarFrame" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')"> + <img class="userAvatarImage mouse-pointer" + ng-click="$parent.goToUserPage(member.id)" + ng-src="{{member.avatar_url || 'img/default-profile.png'}}" + alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}" + title="{{ member.id }} - power: {{ member.powerLevel }}" + width="80" height="80"/> + <!-- <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div> --> + </div> + <div class="userName"> + <pie-chart ng-show="member.powerLevelNorm" data="[ (member.powerLevelNorm + 0), (100 - member.powerLevelNorm) ]"></pie-chart> + {{ member.id | mUserDisplayName:room_id:true }} + <span ng-show="member.last_active_ago" style="color: #aaa">({{ member.last_active_ago + (now - member.last_updated) | duration }})</span> + </div> + </div> </div> <div id="messageTableWrapper" @@ -125,20 +146,21 @@ keep-scroll> <table id="messageTable" infinite-scroll="paginateMore()"> <tr ng-repeat="msg in room.events" - ng-class="(room.events[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item> - <td class="leftBlock"> - <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName: room_id }}</div> + ng-class="(room.events[$index - 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item> + <td class="leftBlock" ng-mouseover="state.showTs = 1" ng-mouseout="state.showTs = 0"> <div class="timestamp" + ng-style="{ 'opacity': state.showTs ? 1.0 : 0.0 }" ng-class="msg.echo_msg_state"> {{ (msg.origin_server_ts) | date:'MMM d HH:mm' }} </div> + <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName:room_id:true }}</div> </td> <td class="avatar"> <!-- msg.__room_member.avatar_url is just backwards compat, and can be removed in the future. --> <img class="avatarImage" ng-src="{{ msg.__room_member.cnt.avatar_url || msg.__room_member.avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}" ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/> </td> - <td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'"> + <td class="msg" ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'"> <div class="bubble" ng-dblclick="openJson(msg)"> <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'"> {{ msg.content.displayname || members[msg.state_key].displayname || msg.state_key }} joined @@ -222,49 +244,10 @@ <div id="controlPanel"> <div id="controls"> - <table id="inputBarTable"> - <tr> - <td id="userIdCell" width="1px"> - {{ state.user_id }} - </td> - <td width="*"> - <textarea id="mainInput" rows="1" ng-enter="send()" - ng-disabled="state.permission_denied" - ng-focus="true" autocomplete="off" tab-complete command-history/> - </td> - <td id="buttonsCell"> - <button ng-click="send()" ng-disabled="state.permission_denied">Send</button> - <button m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied">Image</button> - </td> - </tr> - </table> - - <div class="extraControls"> - <span> - Invite a user: - <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/> - <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button> - </span> - <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave</button> - <button ng-click="startVoiceCall()" - ng-show="(currentCall == undefined || currentCall.state == 'ended')" - ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2" - title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}" - > - Voice Call - </button> - <button ng-click="startVideoCall()" - ng-show="(currentCall == undefined || currentCall.state == 'ended')" - ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2" - title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}" - > - Video Call - </button> - <button ng-click="openRoomInfo()"> - Room Info - </button> - </div> - + <button id="attachButton" m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied"></button> + <textarea id="mainInput" rows="1" ng-enter="send()" + ng-disabled="state.permission_denied" + ng-focus="true" autocomplete="off" tab-complete command-history/> {{ feedback }} <div ng-show="state.stream_failure"> {{ state.stream_failure.data.error || "Connection failure" }} diff --git a/syweb/webclient/test/karma.conf.js b/syweb/webclient/test/karma.conf.js index 7ce958adc9..5f0642ca33 100644 --- a/syweb/webclient/test/karma.conf.js +++ b/syweb/webclient/test/karma.conf.js @@ -22,6 +22,8 @@ module.exports = function(config) { '../js/angular-route.js', '../js/angular-animate.js', '../js/angular-sanitize.js', + '../js/jquery.peity.min.js', + '../js/angular-peity.js', '../js/ng-infinite-scroll-matrix.js', '../js/ui-bootstrap*', '../js/elastic.js', |